From 639d24bd5a3cee315cc6dc583eb39ef8bfa0afbb Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 22 May 2024 12:45:51 +0530 Subject: [PATCH] [WEB-1262] refactor: custom hook for common dropdown logic (#4420) * refactor: custom hook for common dropdown logic * chore: clear query for label dropdowns --- web/components/dropdowns/cycle/index.tsx | 29 ++----- web/components/dropdowns/date-range.tsx | 29 +++---- web/components/dropdowns/date.tsx | 43 ++++------- web/components/dropdowns/estimate.tsx | 53 ++++--------- web/components/dropdowns/member/index.tsx | 29 ++----- web/components/dropdowns/module/index.tsx | 38 +++------- web/components/dropdowns/priority.tsx | 69 ++++++----------- web/components/dropdowns/project.tsx | 52 +++++-------- web/components/dropdowns/state.tsx | 62 +++++---------- .../issue-layouts/properties/labels.tsx | 1 + web/components/issues/select/label.tsx | 1 + web/hooks/use-dropdown.ts | 76 +++++++++++++++++++ 12 files changed, 204 insertions(+), 278 deletions(-) create mode 100644 web/hooks/use-dropdown.ts diff --git a/web/components/dropdowns/cycle/index.tsx b/web/components/dropdowns/cycle/index.tsx index fc7fc6f87..c481f8909 100644 --- a/web/components/dropdowns/cycle/index.tsx +++ b/web/components/dropdowns/cycle/index.tsx @@ -8,8 +8,7 @@ import { ContrastIcon } from "@plane/ui"; import { cn } from "@/helpers/common.helper"; // hooks import { useCycle } from "@/hooks/store"; -import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; +import { useDropdown } from "@/hooks/use-dropdown"; // local components and constants import { DropdownButton } from "../buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; @@ -57,32 +56,18 @@ export const CycleDropdown: React.FC = observer((props) => { const selectedName = value ? getCycleNameById(value) : null; - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + onClose, + setIsOpen, + }); const dropdownOnChange = (val: string | null) => { onChange(val); handleClose(); }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - return ( = (props) => { if (referenceElement) referenceElement.focus(); }; + const { handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + onOpen, + setIsOpen, + }); + const handleClose = () => { if (!isOpen) return; setIsOpen(false); @@ -115,21 +121,6 @@ export const DateRangeDropdown: React.FC = (props) => { if (referenceElement) referenceElement.blur(); }; - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - const disabledDays: Matcher[] = []; if (minDate) disabledDays.push({ before: minDate }); if (maxDate) disabledDays.push({ after: maxDate }); diff --git a/web/components/dropdowns/date.tsx b/web/components/dropdowns/date.tsx index e50379fc9..327bb4598 100644 --- a/web/components/dropdowns/date.tsx +++ b/web/components/dropdowns/date.tsx @@ -7,14 +7,13 @@ import { Combobox } from "@headlessui/react"; import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, getDate } from "@/helpers/date-time.helper"; // hooks -import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; +import { useDropdown } from "@/hooks/use-dropdown"; // components import { DropdownButton } from "./buttons"; -// types -import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; -import { TDropdownProps } from "./types"; // constants +import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; +// types +import { TDropdownProps } from "./types"; type Props = TDropdownProps & { clearIconClassName?: string; @@ -76,34 +75,22 @@ export const DateDropdown: React.FC = (props) => { if (referenceElement) referenceElement.focus(); }; - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - if (referenceElement) referenceElement.blur(); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + onClose, + onOpen, + setIsOpen, + }); const dropdownOnChange = (val: Date | null) => { onChange(val); - if (closeOnSelect) handleClose(); + if (closeOnSelect) { + handleClose(); + referenceElement?.blur(); + } }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - const disabledDays: Matcher[] = []; if (minDate) disabledDays.push({ before: minDate }); if (maxDate) disabledDays.push({ after: maxDate }); diff --git a/web/components/dropdowns/estimate.tsx b/web/components/dropdowns/estimate.tsx index 83e111efe..58243cc22 100644 --- a/web/components/dropdowns/estimate.tsx +++ b/web/components/dropdowns/estimate.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; +import { Fragment, ReactNode, useRef, useState } from "react"; import sortBy from "lodash/sortBy"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; @@ -8,8 +8,7 @@ import { Combobox } from "@headlessui/react"; import { cn } from "@/helpers/common.helper"; // hooks import { useAppRouter, useEstimate } from "@/hooks/store"; -import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; +import { useDropdown } from "@/hooks/use-dropdown"; // components import { DropdownButton } from "./buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; @@ -106,50 +105,26 @@ export const EstimateDropdown: React.FC = observer((props) => { const selectedEstimate = value !== null ? getEstimatePointValue(value, projectId) : null; - const onOpen = () => { - if (!activeEstimate && workspaceSlug) fetchProjectEstimates(workspaceSlug, projectId); + const onOpen = async () => { + if (!activeEstimate && workspaceSlug) await fetchProjectEstimates(workspaceSlug, projectId); }; - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; + const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ + dropdownRef, + inputRef, + isOpen, + onClose, + onOpen, + query, + setIsOpen, + setQuery, + }); const dropdownOnChange = (val: number | null) => { onChange(val); handleClose(); }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - const searchInputKeyDown = (e: React.KeyboardEvent) => { - if (query !== "" && e.key === "Escape") { - e.stopPropagation(); - setQuery(""); - } - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - return ( = observer((props) => { }; if (multiple) comboboxProps.multiple = true; - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + isOpen, + onClose, + setIsOpen, + }); const dropdownOnChange = (val: string & string[]) => { onChange(val); if (!multiple) handleClose(); }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - return ( = observer((props) => { const { getModuleNameById } = useModule(); - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; + const { handleClose, handleKeyDown, handleOnClick } = useDropdown({ + dropdownRef, + inputRef, + isOpen, + onClose, + setIsOpen, + }); const dropdownOnChange = (val: string & string[]) => { onChange(val); if (!multiple) handleClose(); }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - const comboboxProps: any = { value, onChange: dropdownOnChange, diff --git a/web/components/dropdowns/priority.tsx b/web/components/dropdowns/priority.tsx index cfc3db4bf..1bf0bc933 100644 --- a/web/components/dropdowns/priority.tsx +++ b/web/components/dropdowns/priority.tsx @@ -1,22 +1,23 @@ -import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; +import { Fragment, ReactNode, useRef, useState } from "react"; import { useTheme } from "next-themes"; import { usePopper } from "react-popper"; import { Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; -import { TIssuePriorities } from "@plane/types"; -// hooks -import { PriorityIcon, Tooltip } from "@plane/ui"; -import { ISSUE_PRIORITIES } from "@/constants/issue"; -import { cn } from "@/helpers/common.helper"; -import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -// icons -// helpers // types -import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS, BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; -import { TDropdownProps } from "./types"; +import { TIssuePriorities } from "@plane/types"; +// ui +import { PriorityIcon, Tooltip } from "@plane/ui"; // constants +import { ISSUE_PRIORITIES } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useDropdown } from "@/hooks/use-dropdown"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// constants +import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS, BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; +// types +import { TDropdownProps } from "./types"; type Props = TDropdownProps & { button?: ReactNode; @@ -328,38 +329,20 @@ export const PriorityDropdown: React.FC = (props) => { const filteredOptions = query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; - const dropdownOnChange = (val: TIssuePriorities) => { onChange(val); handleClose(); }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - const searchInputKeyDown = (e: React.KeyboardEvent) => { - if (query !== "" && e.key === "Escape") { - e.stopPropagation(); - setQuery(""); - } - }; - - useOutsideClickDetector(dropdownRef, handleClose); + const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ + dropdownRef, + inputRef, + isOpen, + onClose, + query, + setIsOpen, + setQuery, + }); const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant) ? BorderButton @@ -367,12 +350,6 @@ export const PriorityDropdown: React.FC = (props) => { ? BackgroundButton : TransparentButton; - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - return ( = observer((props) => { const selectedProject = value ? getProjectById(value) : null; - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - setIsOpen((prevIsOpen) => !prevIsOpen); - }; + const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ + dropdownRef, + inputRef, + isOpen, + onClose, + query, + setIsOpen, + setQuery, + }); const dropdownOnChange = (val: string) => { onChange(val); handleClose(); }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - return ( = observer((props) => { onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/dropdowns/state.tsx b/web/components/dropdowns/state.tsx index 4711ac084..c6f54f993 100644 --- a/web/components/dropdowns/state.tsx +++ b/web/components/dropdowns/state.tsx @@ -3,20 +3,19 @@ import { observer } from "mobx-react"; import { usePopper } from "react-popper"; import { Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; -// hooks +// ui import { Spinner, StateGroupIcon } from "@plane/ui"; +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import { useAppRouter, useProjectState } from "@/hooks/store"; -import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; +import { useDropdown } from "@/hooks/use-dropdown"; // components import { DropdownButton } from "./buttons"; -// icons -// helpers -// types -import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; -import { TDropdownProps } from "./types"; // constants +import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; +// types +import { TDropdownProps } from "./types"; type Props = TDropdownProps & { button?: ReactNode; @@ -99,51 +98,28 @@ export const StateDropdown: React.FC = observer((props) => { setStateLoader(false); } }; + + const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ + dropdownRef, + inputRef, + isOpen, + onClose, + onOpen, + query, + setIsOpen, + setQuery, + }); + useEffect(() => { if (projectId) onOpen(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - if (isOpen) onClose && onClose(); - }; - const dropdownOnChange = (val: string) => { onChange(val); handleClose(); }; - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - const searchInputKeyDown = (e: React.KeyboardEvent) => { - if (query !== "" && e.key === "Escape") { - e.stopPropagation(); - setQuery(""); - } - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - return ( = observer((pro const handleClose = () => { if (!isOpen) return; setIsOpen(false); + setQuery(""); onClose && onClose(); }; diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index 85706dcb8..fee060d1b 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -57,6 +57,7 @@ export const IssueLabelSelect: React.FC = observer((props) => { const handleClose = () => { if (isDropdownOpen) setIsDropdownOpen(false); if (referenceElement) referenceElement.blur(); + setQuery(""); }; const toggleDropdown = () => { diff --git a/web/hooks/use-dropdown.ts b/web/hooks/use-dropdown.ts new file mode 100644 index 000000000..93be06824 --- /dev/null +++ b/web/hooks/use-dropdown.ts @@ -0,0 +1,76 @@ +import { useEffect } from "react"; +// hooks +import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; + +type TArguments = { + dropdownRef: React.RefObject; + inputRef?: React.RefObject; + isOpen: boolean; + onClose?: () => void; + onOpen?: () => Promise | void; + query?: string; + setIsOpen: React.Dispatch>; + setQuery?: React.Dispatch>; +}; + +export const useDropdown = (args: TArguments) => { + const { dropdownRef, inputRef, isOpen, onClose, onOpen, query, setIsOpen, setQuery } = args; + + /** + * @description clear the search input when the user presses the escape key, if the search input is not empty + * @param {React.KeyboardEvent} e + */ + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery?.(""); + } + }; + + /** + * @description close the dropdown, clear the search input, and call the onClose callback + */ + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose?.(); + setQuery?.(""); + }; + + // toggle the dropdown, call the onOpen callback if the dropdown is closed, and call the onClose callback if the dropdown is open + const toggleDropdown = () => { + if (!isOpen) onOpen?.(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose?.(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + /** + * @description toggle the dropdown on click + * @param {React.MouseEvent} e + */ + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + // close the dropdown when the user clicks outside of the dropdown + useOutsideClickDetector(dropdownRef, handleClose); + + // focus the search input when the dropdown is open + useEffect(() => { + if (isOpen && inputRef?.current) { + inputRef.current.focus(); + } + }, [inputRef, isOpen]); + + return { + handleClose, + handleKeyDown, + handleOnClick, + searchInputKeyDown, + }; +};