- sources page SourceGrid now renders each plugin's summary component with Suspense, passing sourceId. Threads sourcePlugins into the grid. - OpenApiSourceSummary turns into a real ConnectedBadge: reads secretStatusAtom for the configured access token and shows Connected / Not connected / skeleton (initial). Renders nothing for non-OAuth sources so bearer and header-auth sources stay clean.
Unified · Split
| @@ -1,3 +1,55 @@ | ||
| 1 | +import { Result, useAtomValue } from "@effect-atom/atom-react"; | |
| 2 | + | |
| 3 | +import { SecretId } from "@executor/sdk"; | |
| 4 | +import { useScope } from "@executor/react/api/scope-context"; | |
| 5 | +import { secretStatusAtom } from "@executor/react/api/atoms"; | |
| 6 | +import { Badge } from "@executor/react/components/badge"; | |
| 7 | +import { Skeleton } from "@executor/react/components/skeleton"; | |
| 8 | + | |
| 9 | +import { openApiSourceAtom } from "./atoms"; | |
| 10 | + | |
| 11 | +function ConnectedBadge(props: { accessTokenSecretId: string }) { | |
| 12 | + const scopeId = useScope(); | |
| 13 | + const status = useAtomValue( | |
| 14 | + secretStatusAtom(scopeId, SecretId.make(props.accessTokenSecretId)), | |
| 15 | + ); | |
| 16 | + | |
| 17 | + return Result.match(status, { | |
| 18 | + onInitial: () => <Skeleton className="h-5 w-20 rounded-full" />, | |
| 19 | + onFailure: () => ( | |
| 20 | + <Badge variant="outline" className="text-[10px] text-muted-foreground"> | |
| 21 | + Not connected | |
| 22 | + </Badge> | |
| 23 | + ), | |
| 24 | + onSuccess: ({ value }) => | |
| 25 | + value.status === "resolved" ? ( | |
| 26 | + <Badge | |
| 27 | + variant="outline" | |
| 28 | + className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400" | |
| 29 | + > | |
| 30 | + Connected | |
| 31 | + </Badge> | |
| 32 | + ) : ( | |
| 33 | + <Badge variant="outline" className="text-[10px] text-muted-foreground"> | |
| 34 | + Not connected | |
| 35 | + </Badge> | |
| 36 | + ), | |
| 37 | + }); | |
| 38 | +} | |
| 39 | + | |
| 40 | +// The entry row already renders name + id + kind, so this summary | |
| 41 | +// component only contributes extras — specifically, an OAuth status | |
| 42 | +// badge when the source has OAuth2 configured. Non-OAuth sources | |
| 43 | +// render nothing. | |
| 1 | 44 | export default function OpenApiSourceSummary(props: { sourceId: string }) { |
| 2 | − return <span>OpenAPI · {props.sourceId}</span>; | |
| 45 | + const scopeId = useScope(); | |
| 46 | + const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId)); | |
| 47 | + | |
| 48 | + const oauth2 = | |
| 49 | + Result.isSuccess(sourceResult) && sourceResult.value | |
| 50 | + ? sourceResult.value.config.oauth2 | |
| 51 | + : undefined; | |
| 52 | + | |
| 53 | + if (!oauth2) return null; | |
| 54 | + return <ConnectedBadge accessTokenSecretId={oauth2.accessTokenSecretId} />; | |
| 3 | 55 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useState, useCallback, useMemo } from "react"; | |
| 1 | +import { Suspense, useState, useCallback, useMemo } from "react"; | |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { Result, useAtomSet } from "@effect-atom/atom-react"; |
| 4 | 4 | import { detectSource } from "../api/atoms"; |
| @@ -172,7 +172,10 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 167 unmodified lines | ||
| 172 | 172 | <div className="mb-8 space-y-8"> |
| 173 | 173 | {connectedSources.length > 0 && ( |
| 174 | 174 | <section className="space-y-3"> |
| 175 | − <SourceGrid sources={connectedSources} /> | |
| 175 | + <SourceGrid | |
| 176 | + sources={connectedSources} | |
| 177 | + sourcePlugins={sourcePlugins} | |
| 178 | + /> | |
| 176 | 179 | </section> |
| 177 | 180 | )} |
| 178 | 181 | </div> |
| @@ -273,28 +276,45 @@ function SourceGrid(props: { · 94 unmodified lines | ||
| 273 | 276 | url?: string; |
| 274 | 277 | runtime?: boolean; |
| 275 | 278 | }[]; |
| 279 | + sourcePlugins: readonly SourcePlugin[]; | |
| 276 | 280 | }) { |
| 281 | + const pluginByKind = useMemo(() => { | |
| 282 | + const out = new Map<string, SourcePlugin>(); | |
| 283 | + for (const p of props.sourcePlugins) out.set(p.key, p); | |
| 284 | + return out; | |
| 285 | + }, [props.sourcePlugins]); | |
| 286 | + | |
| 277 | 287 | return ( |
| 278 | 288 | <CardStack searchable> |
| 279 | 289 | <CardStackHeader>Connected</CardStackHeader> |
| 280 | 290 | <CardStackContent> |
| 281 | − {props.sources.map((s) => ( | |
| 282 | − <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}> | |
| 283 | − <Link to="/sources/$namespace" params={{ namespace: s.id }}> | |
| 284 | − <CardStackEntryMedia> | |
| 285 | − <SourceFavicon url={s.url} size={32} /> | |
| 286 | − </CardStackEntryMedia> | |
| 287 | − <CardStackEntryContent> | |
| 288 | − <CardStackEntryTitle>{s.name}</CardStackEntryTitle> | |
| 289 | − <CardStackEntryDescription>{s.id}</CardStackEntryDescription> | |
| 290 | − </CardStackEntryContent> | |
| 291 | − <CardStackEntryActions> | |
| 292 | − {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>} | |
| 293 | − <Badge variant="secondary">{s.kind}</Badge> | |
| 294 | − </CardStackEntryActions> | |
| 295 | − </Link> | |
| 296 | − </CardStackEntry> | |
| 297 | − ))} | |
| 291 | + {props.sources.map((s) => { | |
| 292 | + const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind; | |
| 293 | + const plugin = pluginByKind.get(pluginKey); | |
| 294 | + const SummaryComponent = plugin?.summary; | |
| 295 | + return ( | |
| 296 | + <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}> | |
| 297 | + <Link to="/sources/$namespace" params={{ namespace: s.id }}> | |
| 298 | + <CardStackEntryMedia> | |
| 299 | + <SourceFavicon url={s.url} size={32} /> | |
| 300 | + </CardStackEntryMedia> | |
| 301 | + <CardStackEntryContent> | |
| 302 | + <CardStackEntryTitle>{s.name}</CardStackEntryTitle> | |
| 303 | + <CardStackEntryDescription>{s.id}</CardStackEntryDescription> | |
| 304 | + </CardStackEntryContent> | |
| 305 | + <CardStackEntryActions> | |
| 306 | + {SummaryComponent && ( | |
| 307 | + <Suspense fallback={null}> | |
| 308 | + <SummaryComponent sourceId={s.id} /> | |
| 309 | + </Suspense> | |
| 310 | + )} | |
| 311 | + {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>} | |
| 312 | + <Badge variant="secondary">{s.kind}</Badge> | |
| 313 | + </CardStackEntryActions> | |
| 314 | + </Link> | |
| 315 | + </CardStackEntry> | |
| 316 | + ); | |
| 317 | + })} | |
| 298 | 318 | </CardStackContent> |
| 299 | 319 | </CardStack> |
| 300 | 320 | ); |