Skip to content

Commit 9b6e934

Browse files
authored
Merge pull request #169 from seamapi/add-filter-to-supported-devices
🫡
2 parents 20ea720 + 8d33387 commit 9b6e934

15 files changed

+531
-81
lines changed

assets/icons/chevron-down.svg

Lines changed: 8 additions & 0 deletions
Loading

src/lib/capitalize.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function capitalize(str: string): string {
2+
return str.charAt(0).toUpperCase() + str.slice(1)
3+
}

src/lib/icons/ChevronDown.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { SVGProps } from 'react'
2+
export const ChevronDownIcon = (
3+
props: SVGProps<SVGSVGElement>
4+
): JSX.Element => (
5+
<svg
6+
xmlns='http://www.w3.org/2000/svg'
7+
width={20}
8+
height={20}
9+
fill='none'
10+
{...props}
11+
>
12+
<g mask='url(#chevron-down_svg__mask0_1045_100036)'>
13+
<path
14+
fill='#6E7179'
15+
d='m10 12.813-5-5 1.167-1.167L10 10.479l3.833-3.833L15 7.813l-5 5Z'
16+
/>
17+
</g>
18+
</svg>
19+
)

src/lib/ui/Button.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import classNames from 'classnames'
2+
import type { MouseEventHandler, PropsWithChildren } from 'react'
23

34
interface ButtonProps {
4-
children?: string
55
variant?: 'solid' | 'outline' | 'neutral'
66
size?: 'small' | 'medium' | 'large'
77
disabled?: boolean
8-
onClick?: () => void
8+
onClick?: MouseEventHandler<HTMLButtonElement>
9+
className?: string
910
}
1011

1112
export function Button({
@@ -14,12 +15,17 @@ export function Button({
1415
size = 'medium',
1516
disabled,
1617
onClick,
17-
}: ButtonProps): JSX.Element {
18+
className,
19+
}: PropsWithChildren<ButtonProps>): JSX.Element {
1820
return (
1921
<button
20-
className={classNames(`seam-btn seam-btn-${variant} seam-btn-${size}`, {
21-
'seam-btn-disabled': disabled,
22-
})}
22+
className={classNames(
23+
`seam-btn seam-btn-${variant} seam-btn-${size}`,
24+
{
25+
'seam-btn-disabled': disabled,
26+
},
27+
className
28+
)}
2329
disabled={disabled}
2430
onClick={onClick}
2531
>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { ChevronDownIcon } from 'lib/icons/ChevronDown.js'
2+
import { Menu } from 'lib/ui/Menu/Menu.js'
3+
import { MenuItem } from 'lib/ui/Menu/MenuItem.js'
4+
5+
interface FilterCategoryMenuBaseProps {
6+
label: string
7+
options: string[]
8+
onSelect: (option: string) => void
9+
buttonLabel?: string
10+
}
11+
12+
export type FilterCategoryMenuProps =
13+
| (FilterCategoryMenuBaseProps & {
14+
addAllOption: true
15+
onAllOptionSelect: () => void
16+
})
17+
| (FilterCategoryMenuBaseProps & {
18+
addAllOption?: false
19+
onAllOptionSelect?: never
20+
})
21+
22+
export function FilterCategoryMenu({
23+
label,
24+
options,
25+
addAllOption,
26+
onSelect,
27+
onAllOptionSelect,
28+
buttonLabel,
29+
}: FilterCategoryMenuProps) {
30+
const usableOptions = addAllOption !== null ? ['All', ...options] : options
31+
32+
return (
33+
<div className='seam-supported-devices-filter-menu-wrap'>
34+
<p>{label}</p>
35+
<Menu
36+
renderButton={({ onOpen }) => (
37+
<button onClick={onOpen}>
38+
<span>{buttonLabel}</span>
39+
<ChevronDownIcon />
40+
</button>
41+
)}
42+
>
43+
{usableOptions.map((option, index) => (
44+
<MenuItem
45+
key={`${index}:${option}`}
46+
onClick={() => {
47+
if (option === 'All') {
48+
onAllOptionSelect?.()
49+
} else {
50+
onSelect(option)
51+
}
52+
}}
53+
>
54+
<span>{option}</span>
55+
</MenuItem>
56+
))}
57+
</Menu>
58+
</div>
59+
)
60+
}
61+
62+
FilterCategoryMenu.defaultProps = {
63+
addAllOption: true,
64+
buttonLabel: 'Filter',
65+
}

src/lib/ui/SupportedDevices/SupportedDevices.stories.tsx renamed to src/lib/ui/SupportedDeviceTable/SupportedDevice.stories.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,23 @@ import type { Meta, StoryObj } from '@storybook/react'
33

44
import { useToggle } from 'lib/use-toggle.js'
55

6-
import { SupportedDevices } from './SupportedDevices.js'
6+
import { SupportedDeviceTable } from './SupportedDeviceTable.js'
77

88
/**
99
* These stories showcase the supported devices table.
1010
*/
11-
const meta: Meta<typeof SupportedDevices> = {
12-
title: 'Example/SupportedDevices',
13-
component: SupportedDevices,
11+
const meta: Meta<typeof SupportedDeviceTable> = {
12+
title: 'Example/SupportedDeviceTable',
13+
component: SupportedDeviceTable,
1414
tags: ['autodocs'],
1515
}
1616

1717
export default meta
1818

19-
type Story = StoryObj<typeof SupportedDevices>
19+
type Story = StoryObj<typeof SupportedDeviceTable>
2020

2121
export const Content: Story = {
22-
render: (props: any) => <SupportedDevices {...props} />,
22+
render: (props: any) => <SupportedDeviceTable {...props} />,
2323
}
2424

2525
export const InsideModal: Story = {
@@ -31,7 +31,7 @@ export const InsideModal: Story = {
3131
<Button onClick={toggleOpen}>Open Modal</Button>
3232
<Dialog open={open} fullWidth maxWidth='sm' onClose={toggleOpen}>
3333
<div className='seam-components'>
34-
<SupportedDevices {...props} />
34+
<SupportedDeviceTable {...props} />
3535
</div>
3636

3737
<DialogActions
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import axios from 'axios'
3+
import { useCallback, useEffect, useState } from 'react'
4+
5+
import { Button } from 'lib/ui/Button.js'
6+
import { SupportedDeviceFilterArea } from 'lib/ui/SupportedDeviceTable/SupportedDeviceFilterArea.js'
7+
import type { DeviceModel, Filters } from 'lib/ui/SupportedDeviceTable/types.js'
8+
9+
import { SupportedDeviceHeader } from './SupportedDeviceHeader.js'
10+
import { SupportedDeviceRow } from './SupportedDeviceRow.js'
11+
12+
const BASE_URL = 'https://devicedb.seam.co/api/device_models/list'
13+
14+
export interface SupportedDeviceContentProps {
15+
cannotFilter?: boolean
16+
}
17+
18+
export function SupportedDeviceContent({
19+
cannotFilter = false,
20+
}: SupportedDeviceContentProps) {
21+
const [allDeviceModels, setAllDeviceModels] = useState<DeviceModel[]>([])
22+
const [filterValue, setFilterValue] = useState('')
23+
const [filters, setFilters] = useState<Filters>({
24+
supportedOnly: false,
25+
category: null,
26+
brand: null,
27+
})
28+
29+
const fetchDeviceModels = useCallback(async () => {
30+
const queries = []
31+
32+
if (filterValue.trim() !== '') {
33+
queries.push(`text_search=${encodeURIComponent(filterValue.trim())}`)
34+
}
35+
36+
if (filters.supportedOnly) {
37+
queries.push('support_level=live')
38+
}
39+
40+
if (filters.category !== null) {
41+
queries.push(`main_category=${encodeURIComponent(filters.category)}`)
42+
}
43+
44+
if (filters.brand !== null) {
45+
queries.push(`brand=${encodeURIComponent(filters.brand)}`)
46+
}
47+
48+
const url = `${BASE_URL}?${queries.join('&')}`
49+
return await axios.get(url)
50+
}, [filterValue, filters])
51+
52+
const { data, isLoading, isError, refetch } = useQuery<{
53+
data: {
54+
device_models?: DeviceModel[]
55+
}
56+
}>({
57+
queryKey: ['supported_devices', filterValue, filters],
58+
queryFn: fetchDeviceModels,
59+
})
60+
61+
useEffect(() => {
62+
if (
63+
data?.data?.device_models !== undefined &&
64+
allDeviceModels.length === 0
65+
) {
66+
setAllDeviceModels(data.data.device_models)
67+
}
68+
}, [data, allDeviceModels.length])
69+
70+
const deviceModels = data?.data?.device_models ?? []
71+
72+
return (
73+
<div className='seam-supported-devices-table-wrap'>
74+
{!cannotFilter && (
75+
<SupportedDeviceFilterArea
76+
deviceModels={allDeviceModels}
77+
filterValue={filterValue}
78+
setFilterValue={setFilterValue}
79+
filters={filters}
80+
setFilters={setFilters}
81+
/>
82+
)}
83+
84+
{isLoading && (
85+
<div className='seam-supported-devices-table-state-block'>
86+
<p>Loading device models...</p>
87+
</div>
88+
)}
89+
90+
{isError && (
91+
<div className='seam-supported-devices-table-state-block'>
92+
<p>There was an error fetching device models.</p>
93+
<Button
94+
variant='solid'
95+
size='small'
96+
onClick={() => {
97+
void refetch()
98+
}}
99+
>
100+
Retry
101+
</Button>
102+
</div>
103+
)}
104+
105+
{!isLoading && !isError && data?.data?.device_models !== null && (
106+
<table className='seam-supported-devices-table'>
107+
<SupportedDeviceHeader />
108+
<tbody>
109+
{deviceModels.length !== 0 &&
110+
deviceModels.map((deviceModel, index) => (
111+
<SupportedDeviceRow
112+
key={`${index}:${deviceModel.manufacturer_model_id}`}
113+
deviceModel={deviceModel}
114+
/>
115+
))}
116+
117+
{deviceModels.length === 0 && (
118+
<tr className='seam-supported-devices-table-message-row'>
119+
<td colSpan={6}>
120+
<div className='seam-supported-devices-table-message'>
121+
{filterValue.length === 0 ? (
122+
<p>No device models found.</p>
123+
) : (
124+
<>
125+
<p>No device models matched your search.</p>
126+
<Button
127+
variant='outline'
128+
size='small'
129+
onClick={() => {
130+
setFilterValue('')
131+
}}
132+
className='seam-supported-devices-table-message-clear-search'
133+
>
134+
Clear search terms
135+
</Button>
136+
</>
137+
)}
138+
</div>
139+
</td>
140+
</tr>
141+
)}
142+
</tbody>
143+
</table>
144+
)}
145+
</div>
146+
)
147+
}

0 commit comments

Comments
 (0)