Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { screen } from '@testing-library/react'
import { expect, test } from 'vitest'

import {
type EcosystemTestContext,
renderWithEcosystem,
setupFakeEcosystem,
} from 'fixtures/ecosystem.js'

import { AccessoryCatalog, AccessoryLinks } from './AccessoryCatalog.js'

setupFakeEcosystem()

test<EcosystemTestContext>('accessory lookups are batched by slug across manufacturers and use canonical device routes', async (ctx) => {
renderWithEcosystem(
<AccessoryCatalog
slugs={['engage-gateway', 'august-connect-wifi-bridge', 'engage-gateway']}
>
<AccessoryLinks
message='A compatible bridge is required.'
slugs={['engage-gateway']}
/>
<AccessoryLinks
message='A compatible bridge is required.'
slugs={['august-connect-wifi-bridge']}
/>
</AccessoryCatalog>,
ctx
)
const gateway = await screen.findByRole('link', { name: /Engage Gateway/ })
const bridge = await screen.findByRole('link', {
name: /Connect Wi-Fi Bridge/,
})
expect(gateway.getAttribute('href')).toBe(
'https://www.seam.co/gateways/schlage_engage-gateway'
)
expect(bridge.getAttribute('href')).toBe(
'https://www.seam.co/bridges/august_august-connect-wifi-bridge'
)
expect(ctx.ecosystemRequestUrls).toHaveLength(1)
const request = ctx.ecosystemRequestUrls[0]
expect(request?.searchParams.get('slugs')).toBe(
'august-connect-wifi-bridge,engage-gateway'
)
expect(request?.searchParams.has('manufacturer')).toBe(false)
expect(request?.searchParams.has('provider')).toBe(false)
})

test<EcosystemTestContext>('missing accessory records stay explicit while known accessories remain linked', async (ctx) => {
renderWithEcosystem(
<AccessoryCatalog slugs={['engage-gateway', 'missing']}>
<AccessoryLinks
message='A compatible bridge is required.'
slugs={['engage-gateway', 'missing']}
/>
</AccessoryCatalog>,
ctx
)
await screen.findByRole('link', { name: /Engage Gateway/ })
await screen.findByText('Some accessory details couldn’t be loaded.')
expect(screen.queryByRole('link', { name: /missing/ })).toBeNull()
})

test<EcosystemTestContext>('requirements without accessory relations do not fetch an unfiltered catalog', (ctx) => {
renderWithEcosystem(
<AccessoryCatalog slugs={[]}>
<AccessoryLinks message='An external keypad is required.' />
</AccessoryCatalog>,
ctx
)
expect(ctx.ecosystemRequests).toEqual([])
expect(screen.queryByText('Compatible keypads')).toBeNull()
})
130 changes: 130 additions & 0 deletions examples/supported-devices-prototypes/src/AccessoryCatalog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { createContext, type PropsWithChildren, useContext } from 'react'

import {
getDeviceModelImageUrl,
getDeviceModelName,
} from 'lib/seam/components/SupportedDevices/device-models.js'
import type { DeviceModelSummary } from 'lib/seam/ecosystem/types.js'
import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js'

import { getRequirementKinds } from './model.js'

const AccessoryContext = createContext<{
models: DeviceModelSummary[]
isLoading: boolean
isError: boolean
} | null>(null)

/** One batched lookup per catalog, shared by every expanded provider. */
export function AccessoryCatalog({
slugs,
children,
}: PropsWithChildren<{ slugs: string[] }>): JSX.Element {
const { deviceModels, isLoading, isError } = useEcosystemDeviceModelSummaries(
{
slugs,
enabled: slugs.length > 0,
}
)
return (
<AccessoryContext.Provider
value={{ models: deviceModels ?? [], isLoading, isError }}
>
{children}
</AccessoryContext.Provider>
)
}

export function AccessoryLinks({
message,
slugs = [],
}: {
message: string
slugs?: string[] | undefined
}): JSX.Element | null {
const catalog = useContext(AccessoryContext)
if (slugs.length === 0 || catalog == null) return null
const kinds = getRequirementKinds(message)
const label = kinds.includes('bridge')
? 'Compatible bridges'
: kinds.includes('keypad')
? 'Compatible keypads'
: 'Compatible accessories'
const models = [...new Set(slugs)].flatMap((slug) => {
const model = catalog.models.find((entry) => entry.slug === slug)
return model == null ? [] : [model]
})
return (
<div className='seam-proto-accessories'>
<p className='seam-proto-accessories-label'>{label}</p>
{catalog.isLoading ? (
<p className='seam-proto-accessories-state' role='status'>
Loading accessories…
</p>
) : (
<>
{models.length > 0 && (
<ul className='seam-proto-accessory-list' aria-label={label}>
{models.map((model) => {
const image = getDeviceModelImageUrl(model)
const name = getDeviceModelName(model)
const manufacturer = model.manufacturer
const href =
manufacturer == null || model.device_type == null
? null
: `https://www.seam.co/${encodeURIComponent(model.device_type.slug)}s/${encodeURIComponent(manufacturer.slug)}_${encodeURIComponent(model.slug)}`
const content = (
<>
{image != null && (
<img
src={image}
alt=''
loading='lazy'
width='28'
height='28'
/>
)}
<div>
{name}
{manufacturer != null && (
<span>
{manufacturer.display_name ?? manufacturer.slug}
</span>
)}
</div>
{href != null && (
<span className='seam-proto-accessory-arrow' aria-hidden>
↗
</span>
)}
</>
)
return (
<li key={model.device_model_id}>
{href == null ? (
<div className='seam-proto-accessory-link'>{content}</div>
) : (
<a
className='seam-proto-accessory-link'
href={href}
target='_blank'
rel='noopener noreferrer'
>
{content}
</a>
)}
</li>
)
})}
</ul>
)}
{(catalog.isError || models.length < new Set(slugs).size) && (
<p className='seam-proto-accessories-state'>
Some accessory details couldn’t be loaded.
</p>
)}
</>
)}
</div>
)
}
17 changes: 16 additions & 1 deletion examples/supported-devices-prototypes/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosyst
import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js'
import { useEcosystemProviderRequirements } from 'lib/seam/ecosystem/use-ecosystem-provider-requirements.js'

import { AccessoryCatalog } from './AccessoryCatalog.js'
import { BrandMark } from './BrandMark.js'
import { CapabilityFilter } from './CapabilityFilter.js'
import { CheckInfo } from './CheckInfo.js'
Expand Down Expand Up @@ -283,7 +284,21 @@ export function App(): JSX.Element {
key={`${active}-${replay}-${brand.slug}`}
className={`seam-proto-variant seam-proto-variant-${variant.name.toLowerCase()}`}
>
<Variant devices={filtered} requirementView={requirementView} />
<AccessoryCatalog
slugs={devices.flatMap((device) =>
device.providers
.filter(
(provider) => provider.integrationStatus !== 'unsupported'
)
.flatMap((provider) =>
provider.requirements.flatMap(
(requirement) => requirement.deviceSlugs ?? []
)
)
)}
>
<Variant devices={filtered} requirementView={requirementView} />
</AccessoryCatalog>
</div>
)}
<a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RequirementIcon } from 'lib/seam/components/SupportedDevices/RequirementIcon.js'

import { AccessoryLinks } from './AccessoryCatalog.js'
import {
getRequirementKinds,
type RequirementKind,
Expand Down Expand Up @@ -189,10 +190,13 @@ export function ProviderRequirements({
className='seam-compact-requirements'
aria-label='Requirement details'
>
{provider.requirements.map(({ message }) => (
{provider.requirements.map(({ message, deviceSlugs }) => (
<li key={message}>
<RequirementIcon message={message} />
<p>{message}</p>
<div className='seam-proto-requirement-body'>
<p>{message}</p>
<AccessoryLinks message={message} slugs={deviceSlugs} />
</div>
</li>
))}
</ul>
Expand Down
67 changes: 67 additions & 0 deletions examples/supported-devices-prototypes/src/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,70 @@ test('stale device reports cannot classify a provider without metadata', () => {
?.integrationStatus
).toBe('unsupported')
})

test('required accessories follow dependencies and stay specific to each provider', () => {
const bridge = 'A compatible bridge is required.'
const keypad = 'An external keypad is required.'
const code: CapabilitySummary = {
...capability('stable', 'can_program_online_access_codes'),
supported: 'conditionally',
report: {
details: '',
requirements: {
keypad: { message: keypad, device_slugs: ['keypad-a', 'keypad-b'] },
},
dependencies: ['device_connectivity'],
},
}
const connectivity = (slug: string): CapabilitySummary => ({
...capability(null, 'device_connectivity'),
supported: 'conditionally',
report: {
details: '',
requirements: { bridge: { message: bridge, device_slugs: [slug, slug] } },
dependencies: ['can_program_online_access_codes'],
},
})
const model = deviceWithProviders([
{ slug: 'provider-a', capabilities: [code, connectivity('bridge-a')] },
{ slug: 'provider-b', capabilities: [code, connectivity('bridge-b')] },
])
const result = reviewDevice(model)
expect(
result.providers[0]?.requirements.find((r) => r.message === bridge)
?.deviceSlugs
).toEqual(['bridge-a'])
expect(
result.providers[1]?.requirements.find((r) => r.message === bridge)
?.deviceSlugs
).toEqual(['bridge-b'])
expect(
result.providers[0]?.requirements.find((r) => r.message === keypad)
?.deviceSlugs
).toEqual(['keypad-a', 'keypad-b'])
})

test('generic requirements without accessory relations do not invent accessory matches', () => {
const model = deviceWithProviders([
{
slug: 'provider',
capabilities: [
{
...capability('stable'),
supported: 'conditionally',
report: {
details: '',
requirements: {
bridge: { message: 'A compatible bridge is required.' },
},
},
},
],
},
])
expect(
reviewDevice(model).providers[0]?.requirements[0]?.deviceSlugs
).toEqual([])
model.capabilities_stale = true
expect(reviewDevice(model).providers[0]?.requirements).toEqual([])
})
Loading
Loading