Skip to content

Commit

Permalink
Add tooltip to "Remind to Vote" Button if email is unavailable
Browse files Browse the repository at this point in the history
MPR #74
  • Loading branch information
hochiw authored Jul 25, 2022
2 parents ccc73ee + e4e3bb3 commit cf6bdd1
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 97 deletions.
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -152,26 +154,54 @@ const RemindToVoteButton: React.FC<{
proposal: Proposal;
vote: ProposalVote;
}> = ({ proposal, vote }) => {
const { translate } = useLocale();
const [containerRef, setContainerRef] = useState<HTMLElement | null>(null);
const [buttonRef, setButtonRef] = useState<HTMLElement | null>(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 (
<AppButton
type="anchor"
theme="secondary"
size="regular"
className={cn("border", "border-app-grey")}
messageID="ProposalDetail.votes.remindToVote"
href={`mailto:${email}`}
/>
<div
ref={setContainerRef}
className={cn("flex", "cursor-not-allowed", "justify-end")}
>
<AppButton
type="anchor"
theme="secondary"
size="regular"
ref={setButtonRef}
className={cn(
"border",
"border-app-grey",
validatedEmail == null
? cn("bg-gray-300", "pointer-events-none")
: null
)}
messageID="ProposalDetail.votes.remindToVote"
href={validatedEmail != null ? `mailto:${validatedEmail}` : ""}
/>
{validatedEmail == null && (
<Tooltip
parentElement={buttonRef}
triggerElement={containerRef}
content={translate("ProposalDetail.votes.voter.noSecurityContact")}
popperOptions={{
placement: "bottom-start",
modifiers: [{ name: "offset", options: { offset: [20, 10] } }],
}}
/>
)}
</div>
);
};

Expand Down
26 changes: 3 additions & 23 deletions react-app/src/components/common/Buttons/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,23 +17,7 @@ interface IconButtonProps
const IconButton: React.FC<IconButtonProps> = (props) => {
const { icon, size, className, tooltip, onClick: onClick_, ...rest } = props;

const [showTooltip, setShowTooltip] = useState<boolean>(false);
const [refEle, setRefEle] = useState<HTMLButtonElement | null>(null);
const [tooltipEle, setTooltipEle] = useState<HTMLDivElement | null>(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<HTMLButtonElement>) => {
Expand All @@ -51,18 +34,15 @@ const IconButton: React.FC<IconButtonProps> = (props) => {
className={cn("p-2", "hover:bg-gray-100", "rounded-full", className)}
onClick={onClick}
ref={setRefEle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...rest}
>
<Icon icon={icon} height={size} width={size} />
</button>
{showTooltip && (
{tooltip && (
<Tooltip
ref={setTooltipEle}
style={styles.popper}
content={tooltip}
{...attributes.popper}
parentElement={refEle}
popperOptions={{ placement: "bottom" }}
/>
)}
</>
Expand Down
36 changes: 6 additions & 30 deletions react-app/src/components/common/ColorBar/ColorBar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -48,13 +47,7 @@ const ColorBarSection: React.FC<ColorBarSectionProps> = (props) => {
null
);

const [showTooltip, setShowTooltip] = useState<boolean>(false);
const [refEle, setRefEle] = useState<HTMLDivElement | null>(null);
const [tooltipEle, setTooltipEle] = useState<HTMLDivElement | null>(null);

const { styles, attributes, update } = usePopper(refEle, tooltipEle, {
placement: "bottom",
});

const percentage = useMemo(() => {
if (total.isZero()) {
Expand Down Expand Up @@ -87,18 +80,6 @@ const ColorBarSection: React.FC<ColorBarSectionProps> = (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]);
Expand All @@ -122,8 +103,6 @@ const ColorBarSection: React.FC<ColorBarSectionProps> = (props) => {
width: percentage,
}}
ref={setRefEle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span
ref={setPercentageEl}
Expand All @@ -132,15 +111,12 @@ const ColorBarSection: React.FC<ColorBarSectionProps> = (props) => {
{percentage}
</span>
</div>
{showTooltip && (
<div>
<Tooltip
ref={setTooltipEle}
style={styles.popper}
content={percentage}
{...attributes.popper}
/>
</div>
{showPercentage && (
<Tooltip
parentElement={refEle}
content={percentage}
popperOptions={{ placement: "bottom" }}
/>
)}
</>
);
Expand Down
103 changes: 70 additions & 33 deletions react-app/src/components/common/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
popperOptions?: Partial<Options>;
parentElement: HTMLElement | null;
triggerElement?: HTMLElement | null;
content: React.ReactNode | string;
}

const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>((props, ref) => {
const { content, ...rest } = props;
const Tooltip: React.FC<TooltipProps> = (props) => {
const { content, popperOptions, parentElement, triggerElement, ...rest } =
props;

const [showTooltip, setShowTooltip] = useState<boolean>(false);
const [tooltipEle, setTooltipEle] = useState<HTMLDivElement | null>(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 (
<div ref={ref} {...rest}>
<Transition
show={true}
static={true}
as={Fragment}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
<Transition
ref={setTooltipEle}
show={showTooltip}
static={true}
style={styles.popper}
as={"div"}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
{...attributes.popper}
{...rest}
>
<div
role="tooltip"
className={cn(
"bg-gray-600",
"text-white",
"text-xs",
"h-min",
"py-1",
"px-2",
"rounded-md",
"z-50",
"transition-opacity"
)}
>
<div
role="tooltip"
className={cn(
"bg-gray-600",
"text-white",
"text-xs",
"h-min",
"py-1",
"px-2",
"rounded-md",
"z-50",
"transition-opacity"
)}
>
{content}
</div>
</Transition>
</div>
{content}
</div>
</Transition>
);
});
};

export default Tooltip;
1 change: 1 addition & 0 deletions react-app/src/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,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",
Expand Down
1 change: 1 addition & 0 deletions react-app/src/i18n/translations/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,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}",
Expand Down
33 changes: 33 additions & 0 deletions react-app/src/utils/__tests__/regex.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 9 additions & 0 deletions react-app/src/utils/regex.ts
Original file line number Diff line number Diff line change
@@ -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);
}

0 comments on commit cf6bdd1

Please sign in to comment.