Skip to content

Commit 00fd340

Browse files
NicolappsConvex, Inc.
authored andcommitted
Fix and improve the types of <Button> (#36680)
This modifies the prop types of `<Button>` so that: - The types for links formatted as buttons are treated separately than the ones for standard button. In particular I renamed `onClick` to `onClickOfAnchorLink` for links to avoid people setting both `href` and `onClick` by mistake. - We avoid exposing all HTML button attributes and use an allowlist instead. This avoids some annoying cases (e.g. we were setting the `color` property somewhere which is valid HTML but definitely now that we want to do) - We avoid writing `{...(props as any)}` which breaks type safety (this was causing the passHref type bugs!) GitOrigin-RevId: 004fd1b2fcd9730e5f940507d020b8c880f77c10
1 parent 628d9bd commit 00fd340

File tree

14 files changed

+134
-74
lines changed

14 files changed

+134
-74
lines changed

npm-packages/@convex-dev/design-system/src/Button.tsx

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,34 @@ export type ButtonProps = {
1616
disabled?: boolean;
1717
tip?: React.ReactNode;
1818
tipSide?: TooltipSide;
19-
} & (
20-
| React.ButtonHTMLAttributes<HTMLButtonElement>
21-
| {
22-
href: React.AnchorHTMLAttributes<HTMLAnchorElement>["href"] | UrlObject;
23-
onClick?: React.AnchorHTMLAttributes<HTMLAnchorElement>["onClick"];
24-
target?: React.AnchorHTMLAttributes<HTMLAnchorElement>["target"];
25-
}
26-
);
19+
} & Pick<
20+
React.HTMLProps<HTMLElement>,
21+
| "tabIndex"
22+
| "role"
23+
| "aria-label"
24+
| "style"
25+
| "title"
26+
| "onMouseOver"
27+
| "onKeyDown"
28+
> &
29+
(
30+
| {
31+
href?: never;
32+
onClick?: React.ButtonHTMLAttributes<HTMLButtonElement>["onClick"];
33+
onClickOfAnchorLink?: never;
34+
type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
35+
target?: never;
36+
}
37+
| {
38+
href: React.AnchorHTMLAttributes<HTMLAnchorElement>["href"] | UrlObject;
39+
onClick?: never;
40+
// In most cases you shouldn’t use this. This is only useful when you
41+
// need the event sent before the native link behavior is handled.
42+
onClickOfAnchorLink?: React.AnchorHTMLAttributes<HTMLAnchorElement>["onClick"];
43+
type?: never;
44+
target?: React.AnchorHTMLAttributes<HTMLAnchorElement>["target"];
45+
}
46+
);
2747

2848
export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
2949
{
@@ -42,7 +62,7 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
4262
ref,
4363
) {
4464
const Link = useContext(UIContext);
45-
const { href, onClick, target, type } =
65+
const { href, onClick, target, type, onClickOfAnchorLink, ...htmlProps } =
4666
"href" in props
4767
? { ...props, type: undefined }
4868
: { ...props, href: undefined, target: undefined };
@@ -64,13 +84,14 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
6484
<Link
6585
passHref
6686
href={href}
87+
// There is something weird here with `forwardRef`, I’d expect this to work without `any`
6788
ref={ref as any}
6889
role="link"
6990
className={buttonClassName}
7091
tabIndex={0}
7192
target={target}
72-
onClick={onClick}
73-
{...(props as any)}
93+
onClick={onClickOfAnchorLink}
94+
{...htmlProps}
7495
>
7596
{icon && <div>{icon}</div>}
7697
{children}
@@ -86,11 +107,12 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button(
86107
// eslint-disable-next-line react/button-has-type
87108
type={type ?? "button"}
88109
tabIndex={0}
89-
onClick={onClick as any}
110+
onClick={onClick}
90111
className={buttonClassName}
91112
disabled={disabled}
113+
// There is something weird here with `forwardRef`, I’d expect this to work without `any`
92114
ref={ref as any}
93-
{...(props as any)}
115+
{...htmlProps}
94116
>
95117
{/* This needs to be wrapped in a dom element to
96118
fix an issue with the google translate extension

npm-packages/@convex-dev/design-system/src/Menu.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,22 @@ export function MenuItem({
7373
}: {
7474
variant?: "default" | "danger";
7575
children: ReactNode;
76-
action?: () => void;
77-
href?: string;
7876
disabled?: boolean;
7977
tip?: ReactNode;
8078
tipSide?: TooltipSide;
8179
shortcut?: Key[];
82-
}) {
80+
} & (
81+
| {
82+
action: () => void;
83+
href?: never;
84+
}
85+
| {
86+
action?: never;
87+
href: string;
88+
}
89+
)) {
90+
const actionProp = href ? { href } : { onClick: action };
91+
8392
return (
8493
<HeadlessMenu.Item>
8594
{({ active }) => (
@@ -98,8 +107,7 @@ export function MenuItem({
98107
: "text-content-primary",
99108
)}
100109
disabled={disabled}
101-
onClick={action}
102-
href={href}
110+
{...actionProp}
103111
>
104112
{children}
105113
{shortcut && (

npm-packages/@convex-dev/design-system/src/UIContext.tsx

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { omit } from "lodash-es";
22
import {
33
ComponentType,
44
createContext,
5+
forwardRef,
56
PropsWithChildren,
67
useContext,
78
} from "react";
@@ -14,7 +15,8 @@ type LinkProps = {
1415
tabIndex?: number;
1516
target?: string;
1617
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
17-
};
18+
passHref?: boolean;
19+
} & React.RefAttributes<HTMLAnchorElement>;
1820

1921
function encodeQuery(query: UrlObject["query"]): string {
2022
if (!query) return "";
@@ -34,24 +36,23 @@ function encodeQuery(query: UrlObject["query"]): string {
3436
}
3537

3638
// Default link component that just renders an anchor tag
37-
function DefaultLink({
38-
href,
39-
children,
40-
...props
41-
}: PropsWithChildren<LinkProps>) {
42-
return (
43-
<a
44-
href={
45-
typeof href === "string"
46-
? href
47-
: `${href.pathname}${encodeQuery(href.query)}${href.hash || ""}`
48-
}
49-
{...omit(props, "passHref")}
50-
>
51-
{children}
52-
</a>
53-
);
54-
}
39+
const DefaultLink = forwardRef<HTMLAnchorElement, PropsWithChildren<LinkProps>>(
40+
function DefaultLink({ href, children, ...props }, ref) {
41+
return (
42+
<a
43+
href={
44+
typeof href === "string"
45+
? href
46+
: `${href.pathname}${encodeQuery(href.query)}${href.hash || ""}`
47+
}
48+
{...omit(props, "passHref")}
49+
ref={ref}
50+
>
51+
{children}
52+
</a>
53+
);
54+
},
55+
);
5556

5657
// Create context with the default link component
5758
export const UIContext =

npm-packages/dashboard-common/.eslintrc.cjs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,19 @@ module.exports = {
7878
},
7979
],
8080

81-
"no-restricted-imports": [2, { paths: ["lodash"] }],
81+
"no-restricted-imports": [
82+
2,
83+
{
84+
paths: ["lodash"],
85+
patterns: [
86+
{
87+
group: ["react-day-picker"],
88+
importNames: ["Button"],
89+
message: "You probably mean to import from @ui/Button.",
90+
},
91+
],
92+
},
93+
],
8294
// http://eslint.org/docs/rules/no-restricted-syntax
8395
"no-restricted-syntax": [
8496
"error",
@@ -175,7 +187,7 @@ module.exports = {
175187
"tailwindcss/no-custom-classname": [
176188
"error",
177189
{
178-
whitelist: ["bottom-four"],
190+
whitelist: ["bottom-four", "js-launch-kapa-ai"],
179191
},
180192
],
181193
},

npm-packages/dashboard-common/src/elements/DateRangePicker.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export function DateRangePicker({
3333
placement="bottom-start"
3434
button={
3535
<Button
36-
id="date"
3736
variant="neutral"
3837
className="w-fit justify-start text-left font-normal"
3938
icon={<CalendarIcon className="size-4" />}

npm-packages/dashboard-common/src/elements/EmptySection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function EmptySection({
1818
header: string;
1919
body: React.ReactNode;
2020
action?: React.ReactNode;
21-
learnMoreButton?: ButtonProps;
21+
learnMoreButton?: ButtonProps & { href: string };
2222
sheet?: boolean;
2323
}) {
2424
const Parent = sheet ? Sheet : "div";

npm-packages/dashboard-common/src/features/functionRunner/components/RunHistory.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function RunHistory({
4646
variant="neutral"
4747
tip="Jump to Code"
4848
href={`${url}#code`}
49-
onClick={() => log("jump to code from function runner")}
49+
onClickOfAnchorLink={() => log("jump to code from function runner")}
5050
/>
5151
<Button
5252
onClick={() => {

npm-packages/dashboard-common/src/features/functions/components/FunctionItem.tsx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -82,26 +82,24 @@ export function DirectoryItem({
8282
children: React.ReactNode[];
8383
disclosure?: boolean;
8484
}) {
85-
const Btn = disclosure ? Disclosure.Button : Button;
86-
return (
87-
<Btn
88-
variant="unstyled"
89-
className={cn(
90-
sidebarLinkClassNames({
91-
isActive,
92-
font: "mono",
93-
small: true,
94-
}),
95-
"px-0 py-0 w-full min-w-full max-w-full truncate h-[30px] pr-2",
96-
"rounded-none",
97-
isActive &&
98-
"outline outline-util-accent/40 bg-util-accent/30 hover:bg-util-accent/30 font-normal",
99-
!isActive && "hover:bg-util-accent/20",
100-
"focus-visible:outline-none focus-visible:ring-0 focus-visible:bg-util-accent/20",
101-
)}
102-
href={href}
103-
onClick={onClick}
104-
>
85+
const { captureMessage } = useContext(DeploymentInfoContext);
86+
87+
const className = cn(
88+
sidebarLinkClassNames({
89+
isActive,
90+
font: "mono",
91+
small: true,
92+
}),
93+
"px-0 py-0 w-full min-w-full max-w-full truncate h-[30px] pr-2",
94+
"rounded-none",
95+
isActive &&
96+
"outline outline-util-accent/40 bg-util-accent/30 hover:bg-util-accent/30 font-normal",
97+
!isActive && "hover:bg-util-accent/20",
98+
"focus-visible:outline-none focus-visible:ring-0 focus-visible:bg-util-accent/20",
99+
);
100+
101+
const buttonChildren = (
102+
<>
105103
<div
106104
className="flex h-full items-center gap-4"
107105
style={{
@@ -117,6 +115,29 @@ export function DirectoryItem({
117115
))}
118116
</div>
119117
{children}
120-
</Btn>
118+
</>
119+
);
120+
121+
if (disclosure) {
122+
if (href) {
123+
captureMessage("DirectoryItem with href and disclosure");
124+
}
125+
126+
return (
127+
<Disclosure.Button className={className} onClick={onClick}>
128+
{buttonChildren}
129+
</Disclosure.Button>
130+
);
131+
}
132+
133+
return (
134+
<Button
135+
variant="unstyled"
136+
className={className}
137+
href={href}
138+
onClickOfAnchorLink={onClick}
139+
>
140+
{buttonChildren}
141+
</Button>
121142
);
122143
}

npm-packages/dashboard-common/src/features/health/components/IntegrationStatus.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ function IntegrationStatusCard({
113113
tip={`Go to ${integrationName(configuredIntegrations[0].config.type)}`}
114114
icon={<ExternalLinkIcon />}
115115
href={configToUrl(configuredIntegrations[0].config)}
116-
onClick={() => log("go to integration via health")}
116+
onClickOfAnchorLink={() => log("go to integration via health")}
117117
target="_blank"
118118
size="xs"
119119
inline
@@ -124,7 +124,7 @@ function IntegrationStatusCard({
124124
tip="Configure Integrations"
125125
icon={<GearIcon />}
126126
href={integrationsPageLink}
127-
onClick={() => log("configure integrations via health")}
127+
onClickOfAnchorLink={() => log("configure integrations via health")}
128128
target="_blank"
129129
size="xs"
130130
inline

npm-packages/dashboard/src/components/deploymentSettings/BackupListItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export function BackupListItem({
178178
href={
179179
backup.state === "complete"
180180
? getZipExportUrl(backup.snapshotId)
181-
: undefined
181+
: "" // only when disabled
182182
}
183183
tipSide="left"
184184
tip={

npm-packages/dashboard/src/components/header/ProjectSelector/ProjectSelector.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ function ProjectSelectorPanel({
226226
<Button
227227
size="xs"
228228
href={`/t/${team.slug}/settings`}
229-
onClick={close}
229+
onClickOfAnchorLink={close}
230230
inline
231231
variant="neutral"
232232
icon={<GearIcon />}
@@ -287,7 +287,7 @@ function ProjectSelectorPanel({
287287
<Button
288288
size="xs"
289289
href={`/t/${team.slug}/${lastHoveredProject.slug}/settings`}
290-
onClick={close}
290+
onClickOfAnchorLink={close}
291291
inline
292292
variant="neutral"
293293
icon={<GearIcon />}

npm-packages/dashboard/src/components/projectSettings/CustomDomains.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,6 @@ function VanityDomainForm({
447447
<Button
448448
className="flex w-fit"
449449
type="submit"
450-
color="primary"
451450
disabled={
452451
disabled ||
453452
formState.isSubmitting ||

npm-packages/dashboard/src/components/referral/ReferralsBanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useId, useState } from "react";
1111
interface ReferralsBannerProps {
1212
team: Team;
1313
referralState: ReferralState;
14-
onHide?: () => void;
14+
onHide: () => void;
1515
className?: string;
1616
}
1717

npm-packages/dashboard/src/elements/AskAI.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function AskAI() {
99
src="https://widget.kapa.ai/kapa-widget.bundle.js"
1010
data-modal-title="Convex AI"
1111
data-button-hide="true"
12-
data-modal-override-open-id="js-launch-kapa-ai"
12+
data-modal-override-open-class="js-launch-kapa-ai"
1313
data-website-id="8dfb3aad-6006-4f56-b2ed-75fa8051db22"
1414
data-project-name="Convex"
1515
data-project-color="#3F5295"
@@ -20,9 +20,7 @@ export function AskAI() {
2020
/>
2121
<Button
2222
inline
23-
id="js-launch-kapa-ai"
24-
type="button"
25-
className="flex items-center gap-1 px-2.5 text-sm text-content-primary"
23+
className="js-launch-kapa-ai flex items-center gap-1 px-2.5 text-sm text-content-primary"
2624
>
2725
<SparklesIcon className="size-4" />
2826
<span className="hidden md:block">Ask AI</span>

0 commit comments

Comments
 (0)