Skip to content

Commit ce3b0a3

Browse files
wojtekmajKsavinN
andauthored
Convert to TypeScript (#66)
Co-authored-by: Borys Palka <34033913+KsavinN@users.noreply.github.com>
1 parent 871628a commit ce3b0a3

File tree

8 files changed

+464
-211
lines changed

8 files changed

+464
-211
lines changed

.babelrc

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,3 @@
11
{
2-
"presets": [
3-
[
4-
"@babel/preset-env",
5-
{
6-
"loose": true
7-
}
8-
],
9-
"@babel/react"
10-
],
11-
"env": {
12-
"production-esm": {
13-
"presets": [
14-
[
15-
"@babel/env",
16-
{
17-
"modules": false,
18-
"loose": true
19-
}
20-
],
21-
"@babel/react"
22-
]
23-
}
24-
}
2+
"presets": ["@babel/typescript", "@babel/env", "@babel/react"]
253
}

.eslintrc.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
{
2-
"extends": "wojtekmaj/react-no-automatic-runtime"
2+
"extends": [
3+
"wojtekmaj/react-no-automatic-runtime",
4+
"plugin:@typescript-eslint/eslint-recommended",
5+
"plugin:@typescript-eslint/recommended"
6+
],
7+
"parser": "@typescript-eslint/parser",
8+
"plugins": ["@typescript-eslint"]
39
}

package.json

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@
44
"description": "A button that handles Promises for your React app.",
55
"main": "dist/cjs/index.js",
66
"module": "dist/esm/index.js",
7-
"source": "src/index.js",
7+
"source": "src/index.ts",
8+
"types": "src/index.ts",
89
"scripts": {
910
"build": "yarn build-esm && yarn build-cjs",
10-
"build-esm": "BABEL_ENV=production-esm babel src -d dist/esm --ignore \"**/*.spec.js,**/*.spec.jsx\"",
11-
"build-cjs": "BABEL_ENV=production-cjs babel src -d dist/cjs --ignore \"**/*.spec.js,**/*.spec.jsx\"",
11+
"build-esm": "tsc --project tsconfig.build.json --outDir dist/esm --module esnext",
12+
"build-cjs": "tsc --project tsconfig.build.json --outDir dist/cjs --module commonjs",
1213
"clean": "rimraf dist",
1314
"jest": "jest",
14-
"lint": "eslint src --ext .jsx,.js",
15+
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
1516
"postinstall": "husky install",
1617
"prepack": "yarn clean && yarn build",
1718
"prettier": "prettier --check . --cache",
18-
"test": "yarn lint && yarn prettier && yarn jest"
19+
"test": "yarn lint && yarn tsc && yarn prettier && yarn jest",
20+
"tsc": "tsc --noEmit"
1921
},
2022
"keywords": [
2123
"react",
@@ -28,18 +30,22 @@
2830
},
2931
"license": "MIT",
3032
"dependencies": {
31-
"make-cancellable-promise": "^1.0.0",
33+
"@types/react": "*",
34+
"make-cancellable-promise": "^1.2.0",
3235
"prop-types": "^15.6.0"
3336
},
3437
"devDependencies": {
35-
"@babel/cli": "^7.15.0",
3638
"@babel/core": "^7.15.0",
3739
"@babel/preset-env": "^7.15.0",
3840
"@babel/preset-react": "^7.14.0",
41+
"@babel/preset-typescript": "^7.18.6",
3942
"@testing-library/dom": "^8.11.0",
4043
"@testing-library/jest-dom": "^5.15.0",
4144
"@testing-library/react": "^13.4.0",
4245
"@testing-library/user-event": "^14.4.0",
46+
"@types/jest": "^29.0.0",
47+
"@typescript-eslint/eslint-plugin": "^5.41.0",
48+
"@typescript-eslint/parser": "^5.44.0",
4349
"eslint": "^8.26.0",
4450
"eslint-config-wojtekmaj": "^0.7.1",
4551
"husky": "^8.0.0",
@@ -49,7 +55,8 @@
4955
"pretty-quick": "^3.1.0",
5056
"react": "^18.2.0",
5157
"react-dom": "^18.2.0",
52-
"rimraf": "^3.0.0"
58+
"rimraf": "^3.0.0",
59+
"typescript": "^4.9.4"
5360
},
5461
"peerDependencies": {
5562
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"

src/index.spec.jsx renamed to src/index.spec.tsx

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { createRef } from 'react';
22
import { act, render, screen } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44

@@ -26,7 +26,7 @@ describe('<AsyncButton /> component', () => {
2626
errorConfig,
2727
};
2828

29-
let user;
29+
let user: ReturnType<typeof userEvent.setup>;
3030
beforeEach(() => {
3131
user = userEvent.setup({
3232
advanceTimers: jest.advanceTimersByTime,
@@ -40,7 +40,7 @@ describe('<AsyncButton /> component', () => {
4040
});
4141

4242
it('passes ref correctly', () => {
43-
const ref = React.createRef();
43+
const ref = createRef<HTMLButtonElement>();
4444

4545
render(<AsyncButton {...defaultProps} ref={ref} />);
4646

@@ -63,7 +63,9 @@ describe('<AsyncButton /> component', () => {
6363
});
6464

6565
it('changes button state to success on click if onClick is synchronous', async () => {
66-
const onClick = () => {};
66+
const onClick = () => {
67+
// Intentionally empty
68+
};
6769

6870
render(<AsyncButton {...defaultProps} onClick={onClick} />);
6971

@@ -76,7 +78,9 @@ describe('<AsyncButton /> component', () => {
7678
});
7779

7880
it('changes button state to default after refresh timeout has passed', async () => {
79-
const onClick = () => {};
81+
const onClick = () => {
82+
// Intentionally empty
83+
};
8084

8185
render(<AsyncButton {...defaultProps} onClick={onClick} />);
8286

@@ -103,9 +107,9 @@ describe('<AsyncButton /> component', () => {
103107
});
104108

105109
it('changes button state to pending on click if onClick is asynchronous', async () => {
106-
let resolve;
110+
let resolve: () => void;
107111
const onClick = () =>
108-
new Promise((res) => {
112+
new Promise<void>((res) => {
109113
resolve = res;
110114
});
111115

@@ -124,9 +128,9 @@ describe('<AsyncButton /> component', () => {
124128
});
125129

126130
it('changes button state to success after asynchronous onClick is resolved', async () => {
127-
let resolve;
131+
let resolve: () => void;
128132
const onClick = () =>
129-
new Promise((res) => {
133+
new Promise<void>((res) => {
130134
resolve = res;
131135
});
132136

@@ -148,9 +152,9 @@ describe('<AsyncButton /> component', () => {
148152
});
149153

150154
it('changes button state to default after refresh timeout has passed', async () => {
151-
let resolve;
155+
let resolve: () => void;
152156
const onClick = () =>
153-
new Promise((res) => {
157+
new Promise<void>((res) => {
154158
resolve = res;
155159
});
156160

@@ -184,4 +188,71 @@ describe('<AsyncButton /> component', () => {
184188
const button5 = screen.getByRole('button');
185189
expect(button5).toHaveTextContent('Click me');
186190
});
191+
192+
it('should allow button props to be passed by default', () => {
193+
// @ts-expect-no-error
194+
<AsyncButton {...defaultProps} type="submit" />;
195+
});
196+
197+
it('should allow button props to be passed given as="button"', () => {
198+
// @ts-expect-no-error
199+
<AsyncButton {...defaultProps} as="button" disabled />;
200+
});
201+
202+
it('should not allow link props to be passed given as="button"', () => {
203+
// @ts-expect-error-next-line
204+
<AsyncButton {...defaultProps} as="button" href="https://example.com" />;
205+
206+
// Sanity check
207+
// @ts-expect-error-next-line
208+
<button href="https://example.com"></button>;
209+
});
210+
211+
it('should allow link props to be passed given as="a"', () => {
212+
// @ts-expect-no-error
213+
<AsyncButton {...defaultProps} as="a" href="https://example.com" />;
214+
});
215+
216+
it('should not allow button props to be passed given as="a"', () => {
217+
// @ts-expect-error-next-line
218+
<AsyncButton {...defaultProps} as="a" disabled href="https://example.com" />;
219+
220+
// Sanity check
221+
// @ts-expect-error-next-line
222+
<a disabled href="https://example.com">
223+
Click me
224+
</a>;
225+
});
226+
227+
it('should not allow button props to be passed given as={MyButton}', () => {
228+
function MyButton() {
229+
return <button type="submit"></button>;
230+
}
231+
232+
// @ts-expect-error-next-line
233+
<AsyncButton {...defaultProps} as={MyButton} type="submit" />;
234+
235+
// Sanity check
236+
function MyCustomComponent({ as, ...otherProps }: { as: React.ElementType }) {
237+
const Component = as || 'div';
238+
return <Component {...otherProps} />;
239+
}
240+
241+
// @ts-expect-error-next-line
242+
<MyCustomComponent as={MyButton} type="submit" />;
243+
});
244+
245+
it('should not allow invalid values for as', () => {
246+
// @ts-expect-error-next-line
247+
<AsyncButton {...defaultProps} as={5} type="submit" />;
248+
249+
// Sanity check
250+
function MyCustomComponent({ as }: { as: React.ElementType }) {
251+
const Component = as || 'div';
252+
return <Component />;
253+
}
254+
255+
// @ts-expect-error-next-line
256+
<MyCustomComponent as={5} />;
257+
});
187258
});

src/index.jsx renamed to src/index.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,42 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
22
import PropTypes from 'prop-types';
33
import makeCancellable from 'make-cancellable-promise';
44

5+
type Config<T extends React.ElementType> = React.ComponentPropsWithoutRef<T>;
6+
7+
type AsyncButtonProps<T extends React.ElementType> = {
8+
as?: T;
9+
errorConfig?: Config<T>;
10+
onClick?: (event: React.MouseEvent) => void | Promise<void>;
11+
pendingConfig?: Config<T>;
12+
resetTimeout?: number;
13+
successConfig?: Config<T>;
14+
} & Config<T>;
15+
16+
type PolymorphicRef<T extends React.ElementType> = React.ComponentPropsWithRef<T>['ref'];
17+
518
const STATES = {
619
ERROR: 'error',
720
INIT: 'init',
821
PENDING: 'pending',
922
SUCCESS: 'success',
10-
};
23+
} as const;
1124

1225
const AsyncButton = React.forwardRef(
13-
(
26+
<T extends React.ElementType = 'button'>(
1427
{
15-
as = 'button',
28+
as,
1629
errorConfig,
1730
onClick,
1831
pendingConfig,
1932
resetTimeout = 2000,
2033
successConfig,
2134
...otherProps
22-
},
23-
ref,
35+
}: AsyncButtonProps<T>,
36+
ref?: PolymorphicRef<T>,
2437
) => {
25-
const [buttonState, setButtonState] = useState(STATES.INIT);
26-
const cancellablePromise = useRef();
27-
const timeout = useRef();
38+
const [buttonState, setButtonState] = useState<typeof STATES[keyof typeof STATES]>(STATES.INIT);
39+
const cancellablePromise = useRef<ReturnType<typeof makeCancellable>>();
40+
const timeout = useRef<ReturnType<typeof setTimeout>>();
2841

2942
useEffect(
3043
() => () => {
@@ -37,7 +50,11 @@ const AsyncButton = React.forwardRef(
3750
);
3851

3952
const onClickInternal = useCallback(
40-
(event) => {
53+
(event: React.MouseEvent) => {
54+
if (!onClick) {
55+
return;
56+
}
57+
4158
clearTimeout(timeout.current);
4259

4360
const onSuccess = () => {
@@ -76,9 +93,9 @@ const AsyncButton = React.forwardRef(
7693
[onClick, resetTimeout],
7794
);
7895

79-
const Component = as;
96+
const Component = as || 'button';
8097

81-
const buttonConfig = (() => {
98+
const buttonConfig: Config<typeof Component> | null | undefined = (() => {
8299
switch (buttonState) {
83100
case STATES.ERROR:
84101
return errorConfig;
@@ -120,4 +137,6 @@ AsyncButton.propTypes = {
120137
successConfig: isConfigObject,
121138
};
122139

123-
export default AsyncButton;
140+
export default AsyncButton as <T extends React.ElementType = 'button'>(
141+
props: AsyncButtonProps<T> & { ref?: PolymorphicRef<T> },
142+
) => React.ReactElement | null;

tsconfig.build.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"]
4+
}

tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"declaration": true,
4+
"esModuleInterop": true,
5+
"importsNotUsedAsValues": "error",
6+
"isolatedModules": true,
7+
"jsx": "react",
8+
"moduleResolution": "node",
9+
"noUncheckedIndexedAccess": true,
10+
"outDir": "dist",
11+
"strict": true,
12+
"strictNullChecks": true,
13+
"target": "es5"
14+
},
15+
"include": ["src"]
16+
}

0 commit comments

Comments
 (0)