- 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 | export default function OpenApiSourceSummary(props: { sourceId: string }) { | 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 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { Result, useAtomSet } from "@effect-atom/atom-react"; | 3 | import { Result, useAtomSet } from "@effect-atom/atom-react"; |
| 4 | import { detectSource } from "../api/atoms"; | 4 | import { detectSource } from "../api/atoms"; |
| @@ -172,7 +172,10 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 167 unmodified lines | |||
| 172 | <div className="mb-8 space-y-8"> | 172 | <div className="mb-8 space-y-8"> |
| 173 | {connectedSources.length > 0 && ( | 173 | {connectedSources.length > 0 && ( |
| 174 | <section className="space-y-3"> | 174 | <section className="space-y-3"> |
| 175 | <SourceGrid sources={connectedSources} /> | 175 | <SourceGrid |
| 176 | sources={connectedSources} | ||
| 177 | sourcePlugins={sourcePlugins} | ||
| 178 | /> | ||
| 176 | </section> | 179 | </section> |
| 177 | )} | 180 | )} |
| 178 | </div> | 181 | </div> |
| @@ -273,28 +276,45 @@ function SourceGrid(props: { · 94 unmodified lines | |||
| 273 | url?: string; | 276 | url?: string; |
| 274 | runtime?: boolean; | 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 | return ( | 287 | return ( |
| 278 | <CardStack searchable> | 288 | <CardStack searchable> |
| 279 | <CardStackHeader>Connected</CardStackHeader> | 289 | <CardStackHeader>Connected</CardStackHeader> |
| 280 | <CardStackContent> | 290 | <CardStackContent> |
| 281 | {props.sources.map((s) => ( | 291 | {props.sources.map((s) => { |
| 282 | <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}> | 292 | const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind; |
| 283 | <Link to="/sources/$namespace" params={{ namespace: s.id }}> | 293 | const plugin = pluginByKind.get(pluginKey); |
| 284 | <CardStackEntryMedia> | 294 | const SummaryComponent = plugin?.summary; |
| 285 | <SourceFavicon url={s.url} size={32} /> | 295 | return ( |
| 286 | </CardStackEntryMedia> | 296 | <CardStackEntry key={s.id} asChild searchText={`${s.name} ${s.id} ${s.kind}`}> |
| 287 | <CardStackEntryContent> | 297 | <Link to="/sources/$namespace" params={{ namespace: s.id }}> |
| 288 | <CardStackEntryTitle>{s.name}</CardStackEntryTitle> | 298 | <CardStackEntryMedia> |
| 289 | <CardStackEntryDescription>{s.id}</CardStackEntryDescription> | 299 | <SourceFavicon url={s.url} size={32} /> |
| 290 | </CardStackEntryContent> | 300 | </CardStackEntryMedia> |
| 291 | <CardStackEntryActions> | 301 | <CardStackEntryContent> |
| 292 | {s.runtime && <Badge className="bg-muted text-muted-foreground">built-in</Badge>} | 302 | <CardStackEntryTitle>{s.name}</CardStackEntryTitle> |
| 293 | <Badge variant="secondary">{s.kind}</Badge> | 303 | <CardStackEntryDescription>{s.id}</CardStackEntryDescription> |
| 294 | </CardStackEntryActions> | 304 | </CardStackEntryContent> |
| 295 | </Link> | 305 | <CardStackEntryActions> |
| 296 | </CardStackEntry> | 306 | {SummaryComponent && ( |
| 297 | ))} | 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 | </CardStackContent> | 318 | </CardStackContent> |
| 299 | </CardStack> | 319 | </CardStack> |
| 300 | ); | 320 | ); |