diff --git a/react-app/src/components/ProposalDetailScreen/ProposalVotesPanel.tsx b/react-app/src/components/ProposalDetailScreen/ProposalVotesPanel.tsx index 58f0b087..3893f350 100644 --- a/react-app/src/components/ProposalDetailScreen/ProposalVotesPanel.tsx +++ b/react-app/src/components/ProposalDetailScreen/ProposalVotesPanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import cn from "classnames"; import { Link, useOutletContext, useSearchParams } from "react-router-dom"; import { toast } from "react-toastify"; @@ -15,6 +15,8 @@ import { useLocale } from "../../providers/AppLocaleProvider"; import AppButton from "../common/Buttons/AppButton"; import AppRoutes from "../../navigation/AppRoutes"; import { useEffectOnce } from "../../hooks/useEffectOnce"; +import { validateEmail } from "../../utils/regex"; +import Tooltip from "../common/Tooltip/Tooltip"; import { Proposal, ProposalVote, @@ -152,26 +154,54 @@ const RemindToVoteButton: React.FC<{ proposal: Proposal; vote: ProposalVote; }> = ({ proposal, vote }) => { + const { translate } = useLocale(); + const [containerRef, setContainerRef] = useState(null); + const [buttonRef, setButtonRef] = useState(null); + if ( vote.option != null || vote.voter.__typename !== "Validator" || - vote.voter.securityContact == null || proposal.status !== ProposalStatus.VotingPeriod ) { return null; } - const email: string = vote.voter.securityContact; + const email = vote.voter.securityContact; + const validatedEmail: string | null = + email && validateEmail(email) ? email : null; return ( - +
+ + {validatedEmail == null && ( + + )} +
); }; diff --git a/react-app/src/components/common/Buttons/IconButton.tsx b/react-app/src/components/common/Buttons/IconButton.tsx index 4335070f..10ab649a 100644 --- a/react-app/src/components/common/Buttons/IconButton.tsx +++ b/react-app/src/components/common/Buttons/IconButton.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useState } from "react"; import cn from "classnames"; -import { usePopper } from "react-popper"; import { Icon, IconType } from "../Icons/Icons"; import Tooltip from "../Tooltip/Tooltip"; @@ -18,23 +17,7 @@ interface IconButtonProps const IconButton: React.FC = (props) => { const { icon, size, className, tooltip, onClick: onClick_, ...rest } = props; - const [showTooltip, setShowTooltip] = useState(false); const [refEle, setRefEle] = useState(null); - const [tooltipEle, setTooltipEle] = useState(null); - - const { styles, attributes, update } = usePopper(refEle, tooltipEle, { - placement: "bottom", - }); - - const handleMouseEnter = useCallback(() => { - setShowTooltip(true); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - update?.(); - }, [update]); - - const handleMouseLeave = useCallback(() => { - setShowTooltip(false); - }, []); const onClick = useCallback( (e: React.MouseEvent) => { @@ -51,18 +34,15 @@ const IconButton: React.FC = (props) => { className={cn("p-2", "hover:bg-gray-100", "rounded-full", className)} onClick={onClick} ref={setRefEle} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} {...rest} > - {showTooltip && ( + {tooltip && ( )} diff --git a/react-app/src/components/common/ColorBar/ColorBar.tsx b/react-app/src/components/common/ColorBar/ColorBar.tsx index a4a7e9ba..08792485 100644 --- a/react-app/src/components/common/ColorBar/ColorBar.tsx +++ b/react-app/src/components/common/ColorBar/ColorBar.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import cn from "classnames"; import BigNumber from "bignumber.js"; -import { usePopper } from "react-popper"; import { useWindowEvent } from "../../../hooks/useWindowEvent"; import Tooltip from "../Tooltip/Tooltip"; @@ -48,13 +47,7 @@ const ColorBarSection: React.FC = (props) => { null ); - const [showTooltip, setShowTooltip] = useState(false); const [refEle, setRefEle] = useState(null); - const [tooltipEle, setTooltipEle] = useState(null); - - const { styles, attributes, update } = usePopper(refEle, tooltipEle, { - placement: "bottom", - }); const percentage = useMemo(() => { if (total.isZero()) { @@ -87,18 +80,6 @@ const ColorBarSection: React.FC = (props) => { } }, [percentageEl, showPercentage]); - const handleMouseEnter = useCallback(() => { - if (!showPercentage) return; - - setShowTooltip(true); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - update?.(); - }, [showPercentage, update]); - - const handleMouseLeave = useCallback(() => { - setShowTooltip(false); - }, []); - useEffect(() => { handlePercentageDisplay(); }, [handlePercentageDisplay]); @@ -122,8 +103,6 @@ const ColorBarSection: React.FC = (props) => { width: percentage, }} ref={setRefEle} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} > = (props) => { {percentage} - {showTooltip && ( -
- -
+ {showPercentage && ( + )} ); diff --git a/react-app/src/components/common/Tooltip/Tooltip.tsx b/react-app/src/components/common/Tooltip/Tooltip.tsx index be11b74a..43450eca 100644 --- a/react-app/src/components/common/Tooltip/Tooltip.tsx +++ b/react-app/src/components/common/Tooltip/Tooltip.tsx @@ -1,45 +1,82 @@ -import React, { Fragment } from "react"; +import React, { useCallback, useLayoutEffect, useState } from "react"; import cn from "classnames"; import { Transition } from "@headlessui/react"; +import { usePopper } from "react-popper"; +import { Options } from "@popperjs/core"; interface TooltipProps extends React.HTMLAttributes { + popperOptions?: Partial; + parentElement: HTMLElement | null; + triggerElement?: HTMLElement | null; content: React.ReactNode | string; } -const Tooltip = React.forwardRef((props, ref) => { - const { content, ...rest } = props; +const Tooltip: React.FC = (props) => { + const { content, popperOptions, parentElement, triggerElement, ...rest } = + props; + + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipEle, setTooltipEle] = useState(null); + + const { styles, attributes } = usePopper( + parentElement, + tooltipEle, + popperOptions + ); + + const handleMouseEnter = useCallback(() => { + setShowTooltip(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setShowTooltip(false); + }, []); + + useLayoutEffect(() => { + const element = triggerElement ?? parentElement; + element?.addEventListener("mouseenter", handleMouseEnter); + element?.addEventListener("mouseleave", handleMouseLeave); + + return () => { + element?.removeEventListener("mouseenter", handleMouseEnter); + element?.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [handleMouseEnter, handleMouseLeave, parentElement, triggerElement]); + return ( -
- +
-
- {content} -
- -
+ {content} +
+ ); -}); +}; export default Tooltip; diff --git a/react-app/src/i18n/translations/en.json b/react-app/src/i18n/translations/en.json index 34f93a95..19d68bac 100644 --- a/react-app/src/i18n/translations/en.json +++ b/react-app/src/i18n/translations/en.json @@ -100,6 +100,7 @@ "ProposalDetail.votes.remindToVote": "Remind to vote", "ProposalDetail.votes.requestState.error": "Failed to fetch proposal votes, please try again later.", "ProposalDetail.votes.voter": "Voter", + "ProposalDetail.votes.voter.noSecurityContact": "Contact information for this validator is not available", "ProposalDetail.votes.voter.validator": "Validator", "ProposalDetail.votingDateRange": "{from} to {to}", "ProposalDetail.votingDurationRemaining": "{duration} left", diff --git a/react-app/src/i18n/translations/zh.json b/react-app/src/i18n/translations/zh.json index 6fb0f6a1..5bd67758 100644 --- a/react-app/src/i18n/translations/zh.json +++ b/react-app/src/i18n/translations/zh.json @@ -100,6 +100,7 @@ "ProposalDetail.votes.remindToVote": "Remind to vote", "ProposalDetail.votes.requestState.error": "Failed to fetch proposal votes, please try again later.", "ProposalDetail.votes.voter": "Voter", + "ProposalDetail.votes.voter.noSecurityContact": "Contact information for this validator is not available", "ProposalDetail.votes.voter.validator": "Validator", "ProposalDetail.votingDateRange": "{from} 至 {to}", "ProposalDetail.votingDurationRemaining": "剩餘{duration}", diff --git a/react-app/src/utils/__tests__/regex.ts b/react-app/src/utils/__tests__/regex.ts new file mode 100644 index 00000000..b13baa78 --- /dev/null +++ b/react-app/src/utils/__tests__/regex.ts @@ -0,0 +1,33 @@ +import { validateEmail } from "../regex"; + +describe("validateEmail", () => { + it("should fail empty email", () => { + const email = ""; + + expect(validateEmail(email)).toBe(false); + }); + + it("should fail invalid email", () => { + const email = "invalid"; + + expect(validateEmail(email)).toBe(false); + }); + + it("should pass valid email", () => { + const email = "test@exmaple.com"; + + expect(validateEmail(email)).toBe(true); + }); + + it("should pass valid email with plus", () => { + const email = "test+2@example.com"; + + expect(validateEmail(email)).toBe(true); + }); + + it("should pass valid email with dots", () => { + const email = "test.hello.world@example.com"; + + expect(validateEmail(email)).toBe(true); + }); +}); diff --git a/react-app/src/utils/regex.ts b/react-app/src/utils/regex.ts new file mode 100644 index 00000000..e1bb7c15 --- /dev/null +++ b/react-app/src/utils/regex.ts @@ -0,0 +1,9 @@ +const EMAIL_REGEX = + /^$|^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*\.[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + +export function validateEmail(e: string): boolean { + if (!e) { + return false; + } + return EMAIL_REGEX.test(e); +}