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
155 changes: 97 additions & 58 deletions src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useId, useState } from 'react'

import { CheckIcon } from 'lib/icons/Check.js'
import { TriangleWarningOutlineIcon } from 'lib/icons/TriangleWarningOutline.js'
import {
Expand All @@ -22,6 +24,8 @@ export function DeviceModelRow({
deviceModel,
manufacturer,
}: DeviceModelRowProps): JSX.Element {
const detailsId = useId()
const [expanded, setExpanded] = useState(false)
const name = getDeviceModelName(deviceModel)
const imageUrl = getDeviceModelImageUrl(deviceModel, manufacturer)
const modelIds = [
Expand Down Expand Up @@ -74,83 +78,118 @@ export function DeviceModelRow({
)}
</div>
{capabilities.length > 0 && (
<ul className='seam-supported-devices-features' aria-label={t.features}>
{capabilities.map((capability) => (
<li key={capability.name}>
{capability.supported === 'conditionally' ? (
<TriangleWarningOutlineIcon
aria-hidden
className='seam-supported-devices-conditional-icon'
/>
) : (
<CheckIcon aria-hidden />
)}
<span>
{getCapabilityLabel(capability.name)}
{capability.integrationSupportLevel === 'beta' && ' (beta)'}
{capability.supported === 'conditionally' && (
<span className='seam-ecosystem-visually-hidden'>
{` (${t.requirementsBelow})`}
</span>
<div className='seam-supported-devices-capabilities'>
<span className='seam-supported-devices-capabilities-label'>
{t.features}
</span>
<ul
className='seam-supported-devices-features'
aria-label={t.features}
>
{capabilities.map((capability) => (
<li key={capability.name}>
{capability.supported === 'conditionally' ? (
<button
type='button'
className='seam-supported-devices-requirement-toggle'
aria-label={t.toggleRequirements(
getCapabilityLabel(capability.name),
name
)}
aria-controls={detailsId}
aria-expanded={expanded}
onClick={() => {
setExpanded((open) => !open)
}}
>
<TriangleWarningOutlineIcon
aria-hidden
className='seam-supported-devices-conditional-icon'
/>
</button>
) : (
<CheckIcon aria-hidden />
)}
</span>
</li>
))}
</ul>
<span>
{getCapabilityLabel(capability.name)}
{capability.integrationSupportLevel === 'beta' && ' (beta)'}
{capability.supported === 'conditionally' && (
<span className='seam-ecosystem-visually-hidden'>
{` (${t.requirementsDetails})`}
</span>
)}
</span>
</li>
))}
</ul>
</div>
)}
{deviceModel.capabilities_stale ? (
<span className='seam-supported-devices-status'>{t.refreshing}</span>
) : capabilities.length === 0 &&
deviceModel.device_type?.is_accessory !== true ? (
<span className='seam-supported-devices-status'>{t.unconfirmed}</span>
) : null}
{requirements.size > 0 && (
<ul
className='seam-supported-devices-requirements'
aria-label={t.requirements}
{(requirements.size > 0 ||
modelIds.length > 0 ||
providers.length > 0) && (
<details
id={detailsId}
className='seam-supported-devices-details'
open={expanded}
onToggle={(event) => {
setExpanded(event.currentTarget.open)
}}
>
{[...requirements].map(([message, features]) => (
<li key={message}>
<RequirementIcon message={message} />
<div>
<span>{message}</span>
<span className='seam-supported-devices-requirement-features'>
{t.requiredFor([...features].join(', '))}
</span>
</div>
</li>
))}
</ul>
)}
{(modelIds.length > 0 || providers.length > 0) && (
<details className='seam-supported-devices-details'>
<summary>{t.modelDetails}</summary>
<dl>
{modelIds.length > 0 && (
<div>
<dt>{t.modelNumbers}</dt>
<dd>{modelIds.join(', ')}</dd>
</div>
)}
{providers.length > 0 && (
<div>
<dt>{t.integration}</dt>
<dd>{providers.join(', ')}</dd>
</div>
)}
</dl>
{requirements.size > 0 && (
<ul
className='seam-supported-devices-requirements'
aria-label={t.requirements}
>
{[...requirements].map(([message, features]) => (
<li key={message}>
<RequirementIcon message={message} />
<div>
<span>{message}</span>
<span className='seam-supported-devices-requirement-features'>
{t.requiredFor([...features].join(', '))}
</span>
</div>
</li>
))}
</ul>
)}
{(modelIds.length > 0 || providers.length > 0) && (
<dl>
{modelIds.length > 0 && (
<div>
<dt>{t.modelNumbers}</dt>
<dd>{modelIds.join(', ')}</dd>
</div>
)}
{providers.length > 0 && (
<div>
<dt>{t.integration}</dt>
<dd>{providers.join(', ')}</dd>
</div>
)}
</dl>
)}
</details>
)}
</li>
)
}

const t = {
features: 'Supported features',
features: 'Supported capabilities',
requirements: 'Requirements',
requirementsBelow: 'see requirements below',
requirementsDetails: 'expand model and requirements details',
toggleRequirements: (capability: string, device: string) =>
`${capability} requirements for ${device}`,
requiredFor: (features: string) => `For ${features}`,
modelDetails: 'Model details',
modelDetails: 'Model and requirements details',
modelNumbers: 'Model numbers',
integration: 'Integration',
refreshing: 'Compatibility is being refreshed.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export function RequirementIcon({ message }: { message: string }): JSX.Element {
strokeLinecap='round'
strokeLinejoin='round'
>
{/keypad/i.test(message) ? (
{/subscription|licen[sc]e|paying|paid|fees?/i.test(message) ? (
<>
<rect x='2' y='4' width='20' height='16' rx='3' />
<path d='M2 9h20M6 15h4m4 0h4' />
</>
) : /keypad/i.test(message) ? (
<>
<rect x='5' y='2' width='14' height='20' rx='3' />
<path d='M9 6h6M9 10h.01M12 10h.01M15 10h.01M9 14h.01M12 14h.01M15 14h.01M12 18h.01' />
Expand Down
110 changes: 106 additions & 4 deletions src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, test, vi } from 'vitest'

import {
Expand Down Expand Up @@ -106,13 +106,34 @@ test<EcosystemTestContext>('SupportedDevices scopes the query and badges to a pr
expect(request?.searchParams.get('provider')).toBe('schlage-engage')
})

test<EcosystemTestContext>('conditional requirements are readable without hovering', async (ctx) => {
test<EcosystemTestContext>('warning buttons toggle requirements and model details together', async (ctx) => {
renderWithEcosystem(<SupportedDevices manufacturer='august' />, ctx)
const name = await screen.findByText('Smart Lock Pro')
const row = name.closest('li')
if (row == null) throw new Error('Missing row')
const requirement = within(row).getByText(/Requires an August keypad/)
const details = requirement.closest('details')
expect(details?.hasAttribute('open')).toBe(false)
expect(within(row).getByText('Model numbers').closest('details')).toBe(
details
)
const warnings = within(row).getAllByRole('button', {
name: /requirements for/,
})
const warning = warnings[0]
if (warning == null) throw new Error('Missing warning control')
expect(warning.getAttribute('aria-controls')).toBe(details?.id)
fireEvent.click(warning)
await waitFor(() => {
expect(details?.hasAttribute('open')).toBe(true)
})
for (const button of warnings)
expect(button.getAttribute('aria-expanded')).toBe('true')
within(row).getByRole('list', { name: 'Requirements' })
within(row).getByText(/Requires an August keypad/)
fireEvent.click(warning)
await waitFor(() => {
expect(details?.hasAttribute('open')).toBe(false)
})
})

test<EcosystemTestContext>('SupportedDevices shows features and keeps identifiers in model details', async (ctx) => {
Expand All @@ -121,7 +142,7 @@ test<EcosystemTestContext>('SupportedDevices shows features and keeps identifier
const row = name.closest('li')
if (row == null) throw new Error('Missing row')
const badges = within(row).getByRole('list', {
name: 'Supported features',
name: 'Supported capabilities',
})
expect(
within(badges)
Expand All @@ -135,6 +156,87 @@ test<EcosystemTestContext>('SupportedDevices shows features and keeps identifier
expect(within(row).queryByText(/via Schlage Home/)).toBeNull()
})

for (const missingMetadata of [false, true]) {
test<EcosystemTestContext>(`lost provider requirement text ${missingMetadata ? 'keeps a warning if metadata is unavailable' : 'is recovered from the matching provider'}`, async (ctx) => {
const fetch = globalThis.fetch
const providerRequests: URL[] = []
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input, init) => {
const url = new URL(String(input))
if (url.pathname.endsWith('providers/list')) {
providerRequests.push(url)
return new Response(
JSON.stringify({
providers: missingMetadata
? []
: [
{
provider_id: 'august-access',
slug: 'august-access',
display_name: 'August Access',
has_requirements: true,
requirements_md: 'A paid subscription is required.',
},
],
pagination: { has_next_page: false, next_page_cursor: null },
}),
{ status: 200 }
)
}
const response = await fetch(input, init)
if (!url.pathname.endsWith('device_models/summaries/list'))
return response
const data = await response.json()
for (const model of data.device_models) {
for (const entry of model.capabilities_by_provider) {
for (const capability of entry.capabilities) {
if (capability.supported !== 'conditionally') continue
capability.report.requirements = {
...capability.report.requirements,
provider: { message: 'Requirement message not found' },
}
}
}
}
return new Response(JSON.stringify(data), { status: 200 })
})
try {
renderWithEcosystem(
<SupportedDevices manufacturer='august' provider='august-access' />,
ctx
)
const name = await screen.findByText('Smart Lock Pro')
const row = name.closest('li')
if (row == null) throw new Error('Missing row')
await waitFor(() => {
expect(providerRequests).toHaveLength(1)
})
expect(providerRequests[0]?.searchParams.get('slugs')).toBe(
'august-access'
)
const text = await within(row).findByText(
missingMetadata
? 'Additional integration requirements apply. Check with your integration provider.'
: 'A paid subscription is required.'
)
expect(text.closest('details')?.hasAttribute('open')).toBe(false)
const warning = within(row).getAllByRole('button', {
name: /requirements for/,
})[0]
if (warning == null) throw new Error('Missing warning control')
fireEvent.click(warning)
await waitFor(() => {
expect(text.closest('details')?.hasAttribute('open')).toBe(true)
})
within(row).getByText('ASL-03')
expect(screen.queryByText('Requirement message not found')).toBeNull()
} finally {
fetchSpy.mockRestore()
}
})
}

test<EcosystemTestContext>('SupportedDevices defaults to the manufacturer of a manufacturer key', async (ctx) => {
renderWithEcosystem(<SupportedDevices />, {
...ctx,
Expand Down
17 changes: 15 additions & 2 deletions src/lib/seam/components/SupportedDevices/SupportedDevices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
getDeviceTypeOptions,
getManufacturerName,
getManufacturerOptions,
getProvidersWithMissingRequirements,
groupDeviceModelsByManufacturer,
resolveProviderRequirements,
scopeDeviceModelsToProvider,
} from 'lib/seam/components/SupportedDevices/device-models.js'
import { FilterSelect } from 'lib/seam/components/SupportedDevices/FilterSelect.js'
Expand All @@ -20,6 +22,7 @@ import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js'
import type { Manufacturer } from 'lib/seam/ecosystem/types.js'
import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js'
import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js'
import { useEcosystemProviderRequirements } from 'lib/seam/ecosystem/use-ecosystem-provider-requirements.js'
import {
getEmbedKeyManufacturer,
useEmbedKeyResolution,
Expand Down Expand Up @@ -100,7 +103,7 @@ export function SupportedDevices({
enabled: (explicitManufacturer != null || isResolved) && !isResolutionError,
})

const availableDeviceModels = useMemo(
const scopedDeviceModels = useMemo(
() =>
scopeDeviceModelsToProvider(
applyEmbedExclusions(deviceModels ?? [], embedResolution?.exclusions),
Expand All @@ -109,6 +112,16 @@ export function SupportedDevices({
[deviceModels, embedResolution, fixedProvider]
)

const missingProviderSlugs = useMemo(
() => getProvidersWithMissingRequirements(scopedDeviceModels),
[scopedDeviceModels]
)
const { providers } = useEcosystemProviderRequirements(missingProviderSlugs)
const availableDeviceModels = useMemo(
() => resolveProviderRequirements(scopedDeviceModels, providers ?? []),
[scopedDeviceModels, providers]
)

const manufacturerSlugs = useMemo(
() =>
availableDeviceModels
Expand Down Expand Up @@ -403,7 +416,7 @@ const t = {
allCapabilities: 'All features',
featureStatus: 'Feature status',
supported: 'Supported',
requiresSetup: 'Requires setup · see requirements below',
requiresSetup: 'Requires setup · expand details',
error: 'An error occurred while loading supported devices.',
retry: 'Retry',
noDevices: 'No supported devices found.',
Expand Down
Loading
Loading