From ad4f99693be258f170559f387aeb6e38ce96f175 Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Fri, 30 Oct 2026 10:13:10 -0700 Subject: [PATCH] feat: show required accessory device links (DEVDB-476) --- .../src/AccessoryCatalog.test.tsx | 73 ++++++++++ .../src/AccessoryCatalog.tsx | 130 ++++++++++++++++++ .../supported-devices-prototypes/src/App.tsx | 17 ++- .../src/ProviderRequirements.tsx | 8 +- .../src/model.test.ts | 67 +++++++++ .../supported-devices-prototypes/src/model.ts | 59 +++++++- .../src/style.css | 50 +++++++ .../use-ecosystem-device-model-summaries.ts | 9 +- 8 files changed, 403 insertions(+), 10 deletions(-) create mode 100644 examples/supported-devices-prototypes/src/AccessoryCatalog.test.tsx create mode 100644 examples/supported-devices-prototypes/src/AccessoryCatalog.tsx diff --git a/examples/supported-devices-prototypes/src/AccessoryCatalog.test.tsx b/examples/supported-devices-prototypes/src/AccessoryCatalog.test.tsx new file mode 100644 index 000000000..420625892 --- /dev/null +++ b/examples/supported-devices-prototypes/src/AccessoryCatalog.test.tsx @@ -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('accessory lookups are batched by slug across manufacturers and use canonical device routes', async (ctx) => { + renderWithEcosystem( + + + + , + 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('missing accessory records stay explicit while known accessories remain linked', async (ctx) => { + renderWithEcosystem( + + + , + 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('requirements without accessory relations do not fetch an unfiltered catalog', (ctx) => { + renderWithEcosystem( + + + , + ctx + ) + expect(ctx.ecosystemRequests).toEqual([]) + expect(screen.queryByText('Compatible keypads')).toBeNull() +}) diff --git a/examples/supported-devices-prototypes/src/AccessoryCatalog.tsx b/examples/supported-devices-prototypes/src/AccessoryCatalog.tsx new file mode 100644 index 000000000..322c6672f --- /dev/null +++ b/examples/supported-devices-prototypes/src/AccessoryCatalog.tsx @@ -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 ( + + {children} + + ) +} + +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 ( +
+

{label}

+ {catalog.isLoading ? ( +

+ Loading accessories… +

+ ) : ( + <> + {models.length > 0 && ( +
    + {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 && ( + + )} +
    + {name} + {manufacturer != null && ( + + {manufacturer.display_name ?? manufacturer.slug} + + )} +
    + {href != null && ( + + ↗ + + )} + + ) + return ( +
  • + {href == null ? ( +
    {content}
    + ) : ( + + {content} + + )} +
  • + ) + })} +
+ )} + {(catalog.isError || models.length < new Set(slugs).size) && ( +

+ Some accessory details couldn’t be loaded. +

+ )} + + )} +
+ ) +} diff --git a/examples/supported-devices-prototypes/src/App.tsx b/examples/supported-devices-prototypes/src/App.tsx index 3c1676898..6ae1fc81b 100644 --- a/examples/supported-devices-prototypes/src/App.tsx +++ b/examples/supported-devices-prototypes/src/App.tsx @@ -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' @@ -283,7 +284,21 @@ export function App(): JSX.Element { key={`${active}-${replay}-${brand.slug}`} className={`seam-proto-variant seam-proto-variant-${variant.name.toLowerCase()}`} > - + + device.providers + .filter( + (provider) => provider.integrationStatus !== 'unsupported' + ) + .flatMap((provider) => + provider.requirements.flatMap( + (requirement) => requirement.deviceSlugs ?? [] + ) + ) + )} + > + + )} - {provider.requirements.map(({ message }) => ( + {provider.requirements.map(({ message, deviceSlugs }) => (
  • -

    {message}

    +
    +

    {message}

    + +
  • ))} diff --git a/examples/supported-devices-prototypes/src/model.test.ts b/examples/supported-devices-prototypes/src/model.test.ts index 11ec50fd8..ec3c5ec8c 100644 --- a/examples/supported-devices-prototypes/src/model.test.ts +++ b/examples/supported-devices-prototypes/src/model.test.ts @@ -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([]) +}) diff --git a/examples/supported-devices-prototypes/src/model.ts b/examples/supported-devices-prototypes/src/model.ts index 2b3ef5f5d..a23eac563 100644 --- a/examples/supported-devices-prototypes/src/model.ts +++ b/examples/supported-devices-prototypes/src/model.ts @@ -5,6 +5,7 @@ import { getSupportedCapabilities, } from 'lib/seam/components/SupportedDevices/device-models.js' import type { + CapabilitySummary, DeviceAsset, DeviceModelSummary, IntegrationSupportLevel, @@ -22,7 +23,11 @@ export interface ReviewDevice { source: DeviceModelSummary name: string capabilities: Capability[] - requirements: Array<{ message: string; capabilities: string[] }> + requirements: Array<{ + message: string + capabilities: string[] + deviceSlugs?: string[] + }> providers: ReviewProvider[] } @@ -74,7 +79,7 @@ export function reviewDevice( source, name: getDeviceModelName(source), capabilities, - requirements: reviewRequirements(capabilities), + requirements: reviewRequirements(capabilities, source), providers: allProvidersSource.providers .map((provider) => { const details = providers.find((entry) => entry.slug === provider.slug) @@ -82,10 +87,11 @@ export function reviewDevice( allProvidersSource.capabilities_by_provider.filter( (entry) => entry.provider.slug === provider.slug ) - const capabilities = reviewCapabilities({ + const providerSource = { ...allProvidersSource, capabilities_by_provider: providerCapabilities, - }) + } + const capabilities = reviewCapabilities(providerSource) // Provider metadata distinguishes a missing integration from missing // device capabilities. Connectivity alone does not imply Seam support. const levels = @@ -109,7 +115,7 @@ export function reviewDevice( primary: provider.slug === primarySlug, integrationStatus: getIntegrationStatus(levels), capabilities, - requirements: reviewRequirements(capabilities), + requirements: reviewRequirements(capabilities, providerSource), } }) .sort((a, b) => { @@ -146,7 +152,8 @@ function reviewCapabilities(source: DeviceModelSummary): Capability[] { } function reviewRequirements( - capabilities: Capability[] + capabilities: Capability[], + source: DeviceModelSummary ): ReviewDevice['requirements'] { const requirements = new Map() for (const capability of capabilities) @@ -158,9 +165,49 @@ function reviewRequirements( return [...requirements].map(([message, labels]) => ({ message, capabilities: labels, + deviceSlugs: [ + ...new Set( + source.capabilities_by_provider.flatMap( + ({ capabilities: rawCapabilities }) => + rawCapabilities + .filter((capability) => + capabilities.some( + (supported) => + supported.name === capability.name && + supported.messages.includes(message) + ) + ) + .flatMap((capability) => + getRequirementDeviceSlugs(capability, rawCapabilities, message) + ) + ) + ), + ], })) } +/** Follow capability dependencies within the same model/provider only. */ +function getRequirementDeviceSlugs( + capability: CapabilitySummary, + capabilities: CapabilitySummary[], + message: string, + visited = new Set() +): string[] { + if (visited.has(capability.name)) return [] + visited.add(capability.name) + const slugs = Object.values(capability.report?.requirements ?? {}) + .filter((requirement) => requirement.message === message) + .flatMap((requirement) => requirement.device_slugs ?? []) + for (const name of capability.report?.dependencies ?? []) { + const dependency = capabilities.find((entry) => entry.name === name) + if (dependency != null) + slugs.push( + ...getRequirementDeviceSlugs(dependency, capabilities, message, visited) + ) + } + return slugs +} + /** Presentation aliases for known requirements; unfamiliar source copy stays explicit. */ export function getRequirementPills( device: Pick diff --git a/examples/supported-devices-prototypes/src/style.css b/examples/supported-devices-prototypes/src/style.css index f667a1aca..323d4dd34 100644 --- a/examples/supported-devices-prototypes/src/style.css +++ b/examples/supported-devices-prototypes/src/style.css @@ -1017,6 +1017,56 @@ body { font-size: 11px; margin-top: 2px; } +.seam-proto-requirement-body { + flex: 1; + min-width: 0; +} +.seam-proto-accessories { + max-width: 400px; + margin-top: 10px; +} +.seam-proto-accessories-label, +.seam-proto-accessories-state { + color: var(--seam-ecosystem-fg-2); + font-size: 11px; +} +.seam-proto-accessory-list { + margin: 4px 0 0; + padding: 0; + list-style: none; +} +.seam-proto-accessory-list > li { + display: block; +} +.seam-proto-accessory-list > li + li { + border-top: 1px solid var(--seam-ecosystem-line-1); +} +.seam-proto-accessory-link { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 6px 0; + color: var(--seam-ecosystem-fg-1); + font-size: 12px; + line-height: 1.4; + text-decoration: none; +} +a.seam-proto-accessory-link:hover { + color: var(--seam-ecosystem-accent); +} +.seam-proto-accessory-link img { + flex-shrink: 0; + object-fit: contain; +} +.seam-proto-accessory-link > div { + min-width: 0; + overflow-wrap: anywhere; +} +.seam-compact-requirements .seam-proto-accessory-arrow { + margin: 0 0 0 auto; + font-size: 14px; +} .seam-compact-model-info { display: grid; grid-template-columns: 1.5fr 1fr; diff --git a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts index 7c89c6592..a03c68af4 100644 --- a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts +++ b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts @@ -13,7 +13,10 @@ import { } from 'lib/seam/ecosystem/use-ecosystem-query-result.js' export type UseEcosystemDeviceModelSummariesParams = - UseEcosystemDeviceModelsParams + UseEcosystemDeviceModelsParams & { + /** Fetch specific models, including accessories from other manufacturers. */ + slugs?: string[] | undefined + } export type UseEcosystemDeviceModelSummariesData = DeviceModelSummary[] @@ -30,6 +33,7 @@ export function useEcosystemDeviceModelSummaries({ deviceType = '', provider = '', capability = '', + slugs = [], enabled = true, }: UseEcosystemDeviceModelSummariesParams = {}): UseEcosystemQueryResult< 'deviceModels', @@ -42,6 +46,9 @@ export function useEcosystemDeviceModelSummaries({ device_type: deviceType.trim(), provider: provider.trim(), capability: capability.trim(), + slugs: [ + ...new Set(slugs.map((slug) => slug.trim()).filter(Boolean)), + ].sort(), } const { data, ...rest } = useQuery<