Skip to content

Commit b68d547

Browse files
committed
refactor: <Button> to use 'as' prop for links
1 parent 6de315d commit b68d547

File tree

9 files changed

+252
-82
lines changed

9 files changed

+252
-82
lines changed

apps/tup-ui/src/pages/Impersonate/Impersonate.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@ const Impersonate: React.FC = () => {
1919
></input>
2020
</div>
2121
<div>
22-
<a href={`/portal/impersonate?username=${username}`}>
23-
<Button type="primary">Impersonate User</Button>
24-
</a>
22+
<Button
23+
as="a"
24+
href={`/portal/impersonate?username=${username}`}
25+
type="primary"
26+
>
27+
Impersonate User
28+
</Button>
2529
</div>
2630
</div>
2731
);

apps/tup-ui/src/pages/Projects/Projects.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ import { SectionHeader, Button } from '@tacc/core-components';
99
import styles from './Projects.module.css';
1010

1111
const NewProject = () => (
12-
<a href="https://submit-tacc.xras.org/" target="_blank" rel="noreferrer">
13-
<Button type="primary" size="small">
14-
+ New Project
15-
</Button>
16-
</a>
12+
<Button
13+
as="a"
14+
href="https://submit-tacc.xras.org/"
15+
target="_blank"
16+
rel="noreferrer"
17+
type="primary"
18+
size="small"
19+
>
20+
+ New Project
21+
</Button>
1722
);
1823

1924
const Layout: React.FC = () => {

libs/core-components/src/lib/Button/Button.test.jsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React from 'react';
33
import { render } from '@testing-library/react';
44
import '@testing-library/jest-dom/extend-expect';
5+
import { Link, MemoryRouter } from 'react-router-dom';
56
import Button, * as BTN from './Button';
67

78
const TEST_TEXT = '…';
@@ -105,5 +106,49 @@ describe('Button', () => {
105106
const el = queryByText('Loading Button').closest('button');
106107
expect(el).toBeDisabled();
107108
});
109+
it('sets disabled on anchor when loading', () => {
110+
const { getByRole } = render(
111+
<Button as="a" href="#" isLoading={true}>
112+
Loading Link
113+
</Button>
114+
);
115+
const el = getByRole('link');
116+
expect(el).toHaveAttribute('disabled');
117+
});
118+
});
119+
120+
describe('as anchor', () => {
121+
it('renders a link with href', () => {
122+
const { getByRole } = render(
123+
<Button as="a" href="/x" type="primary">
124+
Go
125+
</Button>
126+
);
127+
const el = getByRole('link');
128+
expect(el).toHaveAttribute('href', '/x');
129+
expect(el.textContent).toMatch('Go');
130+
});
131+
it('sets disabled on anchor when disabled', () => {
132+
const { getByRole } = render(
133+
<Button as="a" href="#" disabled>
134+
Nope
135+
</Button>
136+
);
137+
expect(getByRole('link')).toHaveAttribute('disabled');
138+
});
139+
});
140+
141+
describe('as Link', () => {
142+
it('renders router link with to', () => {
143+
const { getByRole } = render(
144+
<MemoryRouter>
145+
<Button as={Link} to="/mfa" type="secondary">
146+
MFA
147+
</Button>
148+
</MemoryRouter>
149+
);
150+
const el = getByRole('link');
151+
expect(el.getAttribute('href')).toMatch('/mfa');
152+
});
108153
});
109154
});

libs/core-components/src/lib/Button/Button.tsx

Lines changed: 125 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { Link } from 'react-router-dom';
23
import Icon from '../Icon';
34
import LoadingSpinner from '../LoadingSpinner';
45
import styles from './Button.module.css';
@@ -33,62 +34,50 @@ type ButtonTypeOtherSize = {
3334
size?: 'short' | 'medium' | 'long' | 'small';
3435
};
3536

36-
type ButtonProps = React.PropsWithChildren<{
37-
className?: string;
38-
iconNameBefore?: string;
39-
iconNameAfter?: string;
40-
id?: string;
41-
dataTestid?: string;
42-
disabled?: boolean;
43-
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
44-
attr?: 'button' | 'submit' | 'reset';
45-
isLoading?: boolean;
46-
}> &
47-
(ButtonTypeLinkSize | ButtonTypeOtherSize) &
48-
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'>;
49-
50-
const Button: React.FC<ButtonProps> = ({
51-
children,
52-
className,
53-
iconNameBefore,
54-
iconNameAfter,
55-
id,
56-
type = 'secondary',
57-
size = '',
58-
dataTestid,
59-
disabled,
60-
onClick,
61-
attr = 'button',
62-
isLoading = false,
63-
...rest
64-
}) => {
37+
const Button = (props: ButtonProps) => {
38+
const {
39+
as,
40+
attr = 'button',
41+
children,
42+
className,
43+
iconNameBefore,
44+
iconNameAfter,
45+
type = 'secondary',
46+
size = '',
47+
dataTestid,
48+
disabled,
49+
onClick,
50+
isLoading = false,
51+
href,
52+
...rest
53+
} = props as ButtonProps & {
54+
href?: string;
55+
attr?: 'button' | 'submit' | 'reset';
56+
};
57+
6558
function onclick(e: React.MouseEvent<HTMLButtonElement>) {
6659
if (disabled) {
6760
e.preventDefault();
6861
return;
6962
}
7063
if (onClick) {
71-
return onClick(e);
64+
return (onClick as React.MouseEventHandler<HTMLButtonElement>)(e);
7265
}
7366
}
7467

75-
return (
76-
<button
77-
{...rest}
78-
id={id}
79-
className={`
68+
const isEffectivelyDisabled = Boolean(disabled || isLoading);
69+
70+
const rootClassName = `
8071
${styles['root']}
8172
c-button
8273
${TYPE_MAP[type] ? `c-button--${[TYPE_MAP[type]]}` : ''}
8374
${SIZE_MAP[size] ? `c-button--${[SIZE_MAP[size]]}` : ''}
8475
${isLoading ? 'c-button--is-busy' : ''}
8576
${className}
86-
`}
87-
disabled={disabled || isLoading}
88-
type={attr}
89-
onClick={onclick}
90-
data-testid={dataTestid}
91-
>
77+
`;
78+
79+
const content = (
80+
<>
9281
{isLoading && (
9382
<LoadingSpinner
9483
placement="inline"
@@ -112,8 +101,103 @@ const Button: React.FC<ButtonProps> = ({
112101
className="c-button__icon--after"
113102
/>
114103
)}
104+
</>
105+
);
106+
107+
const onAnchorOrLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
108+
if (isEffectivelyDisabled) {
109+
e.preventDefault();
110+
return;
111+
}
112+
(onClick as React.MouseEventHandler<HTMLAnchorElement> | undefined)?.(e);
113+
};
114+
115+
if (as === 'a') {
116+
const aProps: React.AnchorHTMLAttributes<HTMLAnchorElement> &
117+
WithDisabledAttr = {
118+
...(rest as React.AnchorHTMLAttributes<HTMLAnchorElement>),
119+
href,
120+
className: rootClassName,
121+
disabled: isEffectivelyDisabled,
122+
onClick: onAnchorOrLinkClick,
123+
'data-testid': dataTestid,
124+
};
125+
return <a {...aProps}>{content}</a>;
126+
}
127+
128+
if (as === Link) {
129+
const linkProps: RouterLinkProps & WithDisabledAttr = {
130+
...(rest as RouterLinkProps),
131+
className: rootClassName,
132+
disabled: isEffectivelyDisabled,
133+
onClick: onAnchorOrLinkClick,
134+
'data-testid': dataTestid,
135+
};
136+
return <Link {...linkProps}>{content}</Link>;
137+
}
138+
139+
return (
140+
<button
141+
{...(rest as Omit<
142+
React.ButtonHTMLAttributes<HTMLButtonElement>,
143+
'type'
144+
>)}
145+
className={rootClassName}
146+
disabled={disabled || isLoading}
147+
type={attr}
148+
onClick={onclick}
149+
data-testid={dataTestid}
150+
>
151+
{content}
115152
</button>
116153
);
117154
};
118155

119156
export default Button;
157+
158+
type SharedProps = React.PropsWithChildren<{
159+
className?: string;
160+
iconNameBefore?: string;
161+
iconNameAfter?: string;
162+
id?: string;
163+
dataTestid?: string;
164+
disabled?: boolean;
165+
isLoading?: boolean;
166+
}> &
167+
(ButtonTypeLinkSize | ButtonTypeOtherSize);
168+
169+
type ButtonAsButtonProps = SharedProps & {
170+
as?: 'button';
171+
attr?: 'button' | 'submit' | 'reset';
172+
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
173+
} & Omit<
174+
React.ButtonHTMLAttributes<HTMLButtonElement>,
175+
'type' | 'disabled' | 'onClick'
176+
>;
177+
178+
type ButtonAsAnchorProps = SharedProps & {
179+
as: 'a';
180+
href: string;
181+
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
182+
} & Omit<
183+
React.AnchorHTMLAttributes<HTMLAnchorElement>,
184+
'type' | 'className' | 'children' | 'disabled' | 'onClick' | 'href'
185+
>;
186+
187+
type RouterLinkProps = React.ComponentPropsWithoutRef<typeof Link>;
188+
189+
type ButtonAsRouterLinkProps = SharedProps & {
190+
as: typeof Link;
191+
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
192+
} & Omit<
193+
RouterLinkProps,
194+
'className' | 'children' | 'disabled' | 'onClick' | 'type'
195+
>;
196+
197+
export type ButtonProps =
198+
| ButtonAsButtonProps
199+
| ButtonAsAnchorProps
200+
| ButtonAsRouterLinkProps;
201+
202+
/** Non-standard `disabled` on `<a>` / `<Link>` — styled via @tacc/core-styles */
203+
type WithDisabledAttr = { disabled?: boolean; 'data-testid'?: string };

libs/tup-components/src/accounts/ManageAccount.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,36 @@ const ManageUser = () => (
1010
Account details are mananged by the TACC Account Management portal. Follow
1111
the links below to:
1212
</p>
13-
<a
13+
<Button
14+
as="a"
1415
href="https://accounts.tacc.utexas.edu/profile"
1516
target="_blank"
1617
rel="noreferrer"
17-
className={`${styles['tap-action']} c-button c-button--secondary`}
18+
className={styles['tap-action']}
19+
type="secondary"
1820
>
1921
Edit User Profile
20-
</a>
21-
<a
22+
</Button>
23+
<Button
24+
as="a"
2225
href="https://accounts.tacc.utexas.edu/change_password"
2326
target="_blank"
2427
rel="noreferrer"
25-
className={`${styles['tap-action']} c-button c-button--secondary`}
28+
className={styles['tap-action']}
29+
type="secondary"
2630
>
2731
Change Password
28-
</a>
29-
<a
32+
</Button>
33+
<Button
34+
as="a"
3035
href="https://accounts.tacc.utexas.edu/mfa"
3136
target="_blank"
3237
rel="noreferrer"
33-
className={`${styles['tap-action']} c-button c-button--secondary`}
38+
className={styles['tap-action']}
39+
type="secondary"
3440
>
3541
Manage Multi-factor Authentication
36-
</a>
42+
</Button>
3743
</article>
3844
);
3945

@@ -44,14 +50,16 @@ const ManageUpload = () => (
4450
To confirm eligibility for access to a TACC account, you may be requested
4551
to upload identifying documents.
4652
</p>
47-
<a
53+
<Button
54+
as="a"
4855
href="https://tacc.utexas.edu/secure-upload"
4956
target="_blank"
5057
rel="noreferrer"
51-
className={`${styles['tap-action']} c-button c-button--secondary`}
58+
className={styles['tap-action']}
59+
type="secondary"
5260
>
5361
Secure File Upload
54-
</a>
62+
</Button>
5563
</article>
5664
);
5765

libs/tup-components/src/accounts/ManageAccountMfa.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,28 @@ export const AccountMfa: React.FC = () => {
4444
Set up multi-factor authentication using a token app or SMS.
4545
</p>
4646
{!hasPairing && (
47-
<Link to="/mfa" className={styles['tap-href']}>
48-
<Button type="primary">Pair Device</Button>
49-
</Link>
47+
<Button
48+
as={Link}
49+
to="/mfa"
50+
className={styles['tap-href']}
51+
type="primary"
52+
>
53+
Pair Device
54+
</Button>
5055
)}
5156
{hasPairing && data.token && (
5257
<div className={styles['mfa-options']}>
5358
<p>
5459
{TOKEN_TYPE[data.token.tokentype]} ({data.token.serial})
5560
</p>
56-
<Link to="/mfa/unpair" className={styles['tap-href']}>
57-
<Button type="secondary">Unpair</Button>
58-
</Link>
61+
<Button
62+
as={Link}
63+
to="/mfa/unpair"
64+
className={styles['tap-href']}
65+
type="secondary"
66+
>
67+
Unpair
68+
</Button>
5969
</div>
6070
)}
6171
</article>

0 commit comments

Comments
 (0)