Skip to content

Commit 1c4750f

Browse files
authored
[UI] Use HOC to apply button styles and forward ref (#592)
1 parent 6f011f3 commit 1c4750f

7 files changed

Lines changed: 150 additions & 114 deletions

File tree

web/client/.eslintrc.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ module.exports = {
1717
rules: {
1818
'react/jsx-uses-react': OFF,
1919
'react/react-in-jsx-scope': OFF,
20+
'no-use-before-define': OFF,
21+
'@typescript-eslint/no-use-before-define': [
22+
ERROR,
23+
{
24+
variables: true,
25+
functions: false,
26+
classes: false,
27+
allowNamedExports: true,
28+
},
29+
],
2030
'@typescript-eslint/no-dynamic-delete': OFF,
2131
'@typescript-eslint/naming-convention': [
2232
ERROR,

web/client/src/library/components/button/Button.tsx

Lines changed: 60 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1+
import React from 'react'
12
import clsx from 'clsx'
23
import {
34
EnumSize,
45
type Size,
56
type Variant,
67
type EnumVariant,
78
} from '../../../types/enum'
8-
import { Menu } from '@headlessui/react'
9-
import { type ForwardedRef, forwardRef } from 'react'
109

1110
export type ButtonVariant = Subset<
1211
Variant,
@@ -89,6 +88,7 @@ const VARIANT = new Map<ButtonVariant, string>([
8988

9089
const SHAPE = new Map<ButtonShape, string>([
9190
['rounded', `rounded-md`],
91+
['square', `rounded-none`],
9292
['circle', `rounded-full`],
9393
])
9494

@@ -99,21 +99,24 @@ const SIZE = new Map<ButtonSize, string>([
9999
[EnumSize.lg, `px-4 py-3 text-lg border-4`],
100100
])
101101

102-
export const Button = forwardRef(function Button(
102+
const Button = makeButton(
103+
React.forwardRef<HTMLButtonElement, PropsButton>(ButtonPlain),
104+
)
105+
106+
export { VARIANT, SHAPE, SIZE, Button, makeButton }
107+
108+
function ButtonPlain(
103109
{
104110
type = 'button',
105111
disabled = false,
106-
variant = 'secondary',
107-
shape = 'rounded',
108-
size = EnumSize.md,
109112
children = [],
110113
form,
111-
className,
112114
autoFocus,
113115
tabIndex,
114116
onClick,
117+
className,
115118
}: PropsButton,
116-
ref: ForwardedRef<HTMLButtonElement>,
119+
ref?: React.ForwardedRef<HTMLButtonElement>,
117120
): JSX.Element {
118121
return (
119122
<button
@@ -123,52 +126,58 @@ export const Button = forwardRef(function Button(
123126
tabIndex={tabIndex}
124127
form={form}
125128
disabled={disabled}
126-
className={clsx(
127-
'whitespace-nowrap flex m-1 items-center justify-center font-bold',
128-
'focus:ring-4 focus:outline-none focus:border-secondary-500',
129-
'ring-secondary-300 ring-opacity-60 ring-offset ring-offset-secondary-100',
130-
SHAPE.get(shape),
131-
SIZE.get(size),
132-
disabled
133-
? 'opacity-50 bg-neutral-10 border-neutral-300 text-prose cursor-not-allowed'
134-
: VARIANT.get(variant),
135-
className,
136-
)}
137129
onClick={onClick}
130+
className={className}
138131
>
139132
{children}
140133
</button>
141134
)
142-
})
135+
}
143136

144-
export const ButtonMenu = forwardRef(function ButtonMenu(
145-
{
146-
variant = 'secondary',
147-
shape = 'rounded',
148-
size = EnumSize.md,
149-
children = [],
150-
disabled = false,
151-
className,
152-
}: PropsButton,
153-
ref: ForwardedRef<HTMLButtonElement>,
154-
): JSX.Element {
155-
return (
156-
<Menu.Button
157-
ref={ref}
158-
className={clsx(
159-
'whitespace-nowrap flex m-1 items-center justify-center',
160-
'border-2 focus:ring-4 focus:outline-none focus:border-secondary-500',
161-
'ring-secondary-300 ring-opacity-60 ring-offset ring-offset-secondary-100',
162-
SHAPE.get(shape),
163-
SIZE.get(size),
164-
disabled
165-
? 'opacity-50 bg-neutral-10 border-neutral-300 text-prose cursor-not-allowed'
166-
: VARIANT.get(variant),
167-
className,
168-
)}
169-
disabled={disabled}
170-
>
171-
{children}
172-
</Menu.Button>
173-
)
174-
})
137+
function makeButton<TElement = HTMLButtonElement>(
138+
Component: React.ElementType,
139+
): React.ForwardRefExoticComponent<
140+
PropsButton & React.RefAttributes<TElement>
141+
> {
142+
return React.forwardRef<TElement, PropsButton>(function Wrapper(
143+
{
144+
type = 'button',
145+
disabled = false,
146+
variant = 'primary',
147+
shape = 'rounded',
148+
size = EnumSize.md,
149+
children = [],
150+
className,
151+
form,
152+
autoFocus,
153+
tabIndex,
154+
onClick,
155+
}: PropsButton,
156+
ref?: React.ForwardedRef<TElement>,
157+
): JSX.Element {
158+
return (
159+
<Component
160+
ref={ref}
161+
type={type}
162+
disabled={disabled}
163+
form={form}
164+
autoFocus={autoFocus}
165+
tabIndex={tabIndex}
166+
onClick={onClick}
167+
className={clsx(
168+
'whitespace-nowrap flex m-1 items-center justify-center font-bold',
169+
'focus:ring-4 focus:outline-none focus:border-secondary-500',
170+
'ring-secondary-300 ring-opacity-60 ring-offset ring-offset-secondary-100',
171+
SHAPE.get(shape),
172+
SIZE.get(size),
173+
disabled
174+
? 'opacity-50 bg-neutral-10 border-neutral-300 text-prose cursor-not-allowed'
175+
: VARIANT.get(variant),
176+
className,
177+
)}
178+
>
179+
{children}
180+
</Component>
181+
)
182+
})
183+
}
Lines changed: 73 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,41 @@
1+
import React from 'react'
12
import { vi } from 'vitest'
2-
import { render, fireEvent } from '../../../../tests/utils'
3-
import { EnumSize } from '../../../../types/enum'
4-
import { Button, ButtonMenu } from '../Button'
5-
import { Menu } from '@headlessui/react'
3+
import { render, fireEvent, renderHook } from '../../../../tests/utils'
4+
import { EnumSize, EnumVariant } from '../../../../types/enum'
5+
import {
6+
Button,
7+
makeButton,
8+
VARIANT,
9+
SHAPE,
10+
SIZE,
11+
EnumButtonShape,
12+
} from '../Button'
613

714
describe('Button', () => {
815
test('renders with default variant, shape, and size', () => {
916
const { getByText } = render(<Button>Click me</Button>)
1017
const button = getByText('Click me')
1118

12-
expect(button).toHaveClass('bg-secondary-500')
13-
expect(button).toHaveClass('rounded-md')
14-
expect(button).toHaveClass('px-3 py-2 text-base')
19+
expect(button).toHaveClass(VARIANT.get(EnumVariant.Primary) as string)
20+
expect(button).toHaveClass(SHAPE.get(EnumButtonShape.Rounded) as string)
21+
expect(button).toHaveClass(SIZE.get(EnumSize.md) as string)
1522
})
1623

1724
test('renders with custom variant, shape, and size', () => {
1825
const { getByText } = render(
1926
<Button
20-
variant="primary"
21-
shape="square"
27+
variant={EnumVariant.Secondary}
28+
shape={EnumButtonShape.Square}
2229
size={EnumSize.sm}
2330
>
2431
Click me
2532
</Button>,
2633
)
2734
const button = getByText('Click me')
2835

29-
expect(button).toHaveClass('bg-secondary-100')
30-
expect(button).not.toHaveClass('rounded-md')
31-
expect(button).toHaveClass('px-2 py-1 text-xs')
36+
expect(button).toHaveClass(VARIANT.get(EnumVariant.Secondary) as string)
37+
expect(button).toHaveClass(SHAPE.get(EnumButtonShape.Square) as string)
38+
expect(button).toHaveClass(SIZE.get(EnumSize.sm) as string)
3239
})
3340

3441
test('calls onClick when clicked', () => {
@@ -43,34 +50,69 @@ describe('Button', () => {
4350

4451
describe('ButtonMenu', () => {
4552
test('renders with default variant, shape, and size', () => {
53+
const ButtonMenu = createButton()
54+
const { getByText } = render(<ButtonMenu>Click me</ButtonMenu>)
55+
const button = getByText('Click me')
56+
57+
expect(button).toHaveClass(VARIANT.get(EnumVariant.Primary) as string)
58+
expect(button).toHaveClass(SHAPE.get(EnumButtonShape.Rounded) as string)
59+
expect(button).toHaveClass(SIZE.get(EnumSize.md) as string)
60+
})
61+
62+
test('renders with custom variant, shape, and size', () => {
63+
const ButtonMenu = createButton()
4664
const { getByText } = render(
47-
<Menu>
48-
<ButtonMenu>Click me</ButtonMenu>
49-
</Menu>,
65+
<ButtonMenu
66+
variant={EnumVariant.Secondary}
67+
shape={EnumButtonShape.Square}
68+
size={EnumSize.sm}
69+
>
70+
Click me
71+
</ButtonMenu>,
5072
)
5173
const button = getByText('Click me')
5274

53-
expect(button).toHaveClass('bg-secondary-500')
54-
expect(button).toHaveClass('rounded-md')
55-
expect(button).toHaveClass('px-3 py-2 text-base')
75+
expect(button).toHaveClass(VARIANT.get(EnumVariant.Secondary) as string)
76+
expect(button).toHaveClass(SHAPE.get(EnumButtonShape.Square) as string)
77+
expect(button).toHaveClass(SIZE.get(EnumSize.sm) as string)
5678
})
5779

58-
test('renders with custom variant, shape, and size', () => {
80+
test('calls onClick when clicked', () => {
81+
const ButtonMenu = createButton()
82+
const onClick = vi.fn()
5983
const { getByText } = render(
60-
<Menu>
61-
<ButtonMenu
62-
variant="primary"
63-
shape="square"
64-
size={EnumSize.sm}
65-
>
66-
Click me
67-
</ButtonMenu>
68-
</Menu>,
84+
<ButtonMenu onClick={onClick}>Click me</ButtonMenu>,
6985
)
7086
const button = getByText('Click me')
7187

72-
expect(button).toHaveClass('bg-secondary-100')
73-
expect(button).not.toHaveClass('rounded-md')
74-
expect(button).toHaveClass('px-2 py-1 text-xs')
88+
fireEvent.click(button)
89+
expect(onClick).toHaveBeenCalled()
90+
})
91+
92+
test('get ref from component', () => {
93+
const ButtonMenu = createButton()
94+
const { result } = renderHook(() => React.useRef<HTMLDivElement>(null))
95+
const { getByText } = render(
96+
<ButtonMenu ref={result.current}>Click me</ButtonMenu>,
97+
)
98+
const button = getByText('Click me')
99+
100+
expect(result.current.current).toBe(button)
75101
})
76102
})
103+
104+
function createButton(): any {
105+
return makeButton(
106+
React.forwardRef<any, any>(function Button({ children, ...props }, ref) {
107+
return (
108+
<div
109+
{...props}
110+
role="button"
111+
ref={ref}
112+
>
113+
{children}
114+
</div>
115+
)
116+
}),
117+
)
118+
}

web/client/src/library/components/ide/RunPlan.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
isStringEmptyOrNil,
2929
debounceAsync,
3030
} from '~/utils'
31-
import { Button, ButtonMenu, type ButtonSize } from '../button/Button'
31+
import { Button, makeButton, type ButtonSize } from '../button/Button'
3232
import { Divider } from '../divider/Divider'
3333
import Input from '../input/Input'
3434
import Spinner from '../logo/Spinner'
@@ -411,6 +411,9 @@ function SelectEnvironemnt({
411411
const environments = useStoreContext(s => s.environments)
412412
const setEnvironment = useStoreContext(s => s.setEnvironment)
413413
const removeLocalEnvironment = useStoreContext(s => s.removeLocalEnvironment)
414+
415+
const ButtonMenu = makeButton<HTMLDivElement>(Menu.Button)
416+
414417
return (
415418
<Menu>
416419
{({ close }) => (

web/client/src/library/components/plan/context.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const EnumPlanChangeType = {
4343

4444
export const EnumCategoryType = {
4545
BreakingChange: 'breaking-change',
46-
NonBreakingChange: 'non-breaking-change'
46+
NonBreakingChange: 'non-breaking-change',
4747
} as const
4848

4949
export type PlanActions = KeyOf<typeof EnumPlanActions>
@@ -147,7 +147,7 @@ const initial = {
147147
export const PlanContext = createContext<PlanDetails>(initial)
148148
export const PlanDispatchContext = createContext<
149149
Dispatch<PlanAction | PlanAction[]>
150-
>(() => { })
150+
>(() => {})
151151

152152
export default function PlanProvider({
153153
children,
@@ -392,7 +392,7 @@ function useCategories(): [Category, Category[]] {
392392
name: 'Non-Breaking Change',
393393
description: 'It will exclude all indirect models caused by this change',
394394
value: SnapshotChangeCategory.NUMBER_2,
395-
}
395+
},
396396
]
397397

398398
return [categoryBreakingChange, categories]

web/client/src/library/components/plan/help.spec.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,6 @@ describe('getActionName', () => {
5757
expect(result).toBe(expected)
5858
})
5959

60-
it('should return "Closing..." when action is EnumPlanAction.Closing', () => {
61-
const action = EnumPlanAction.Closing
62-
const options = [EnumPlanAction.Closing]
63-
const fallback = 'Start'
64-
const expected = 'Closing...'
65-
66-
const result = getActionName(action, options, fallback)
67-
68-
expect(result).toBe(expected)
69-
})
70-
7160
it('should return "Run" when action is EnumPlanAction.Run', () => {
7261
const action = EnumPlanAction.Run
7362
const options = [EnumPlanAction.Run]

0 commit comments

Comments
 (0)