| @@ -28,13 +28,25 @@ import { FieldLabel } from "@executor/react/components/field"; · 27 unmodified lines |
| 28 | import { FloatActions } from "@executor/react/components/float-actions"; | 28 | import { FloatActions } from "@executor/react/components/float-actions"; |
| 29 | import { Input } from "@executor/react/components/input"; | 29 | import { Input } from "@executor/react/components/input"; |
| 30 | import { Label } from "@executor/react/components/label"; | 30 | import { Label } from "@executor/react/components/label"; |
| | 31 | import { |
| | 32 | NativeSelect, |
| | 33 | NativeSelectOption, |
| | 34 | } from "@executor/react/components/native-select"; |
| 31 | import { Textarea } from "@executor/react/components/textarea"; | 35 | import { Textarea } from "@executor/react/components/textarea"; |
| 32 | import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; | 36 | import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; |
| 33 | import { Skeleton } from "@executor/react/components/skeleton"; | 37 | import { Skeleton } from "@executor/react/components/skeleton"; |
| 34 | import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; | 38 | import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; |
| 35 | import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; | 39 | import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; |
| 36 | import type { SpecPreview, HeaderPreset } from "../sdk/preview"; | 40 | import type { SpecPreview, HeaderPreset } from "../sdk/preview"; |
| 37 | import type { HeaderValue } from "../sdk/types"; | 41 | import type { HeaderValue, ServerInfo, ServerVariable } from "../sdk/types"; |
| | 42 | |
| | 43 | const substituteUrlVariables = (url: string, values: Record<string, string>): string => { |
| | 44 | let out = url; |
| | 45 | for (const [name, value] of Object.entries(values)) { |
| | 46 | out = out.replaceAll(`{${name}}`, value); |
| | 47 | } |
| | 48 | return out; |
| | 49 | }; |
| 38 | | 50 | |
| 39 | // --------------------------------------------------------------------------- | 51 | // --------------------------------------------------------------------------- |
| 40 | // Helpers | 52 | // Helpers |
| @@ -79,7 +91,11 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines |
| 79 | | 91 | |
| 80 | // After analysis | 92 | // After analysis |
| 81 | const [preview, setPreview] = useState<SpecPreview | null>(null); | 93 | const [preview, setPreview] = useState<SpecPreview | null>(null); |
| 82 | const [baseUrl, setBaseUrl] = useState(""); | 94 | // -1 means the user is entering a fully custom base URL (no server selected). |
| | 95 | const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1); |
| | 96 | const [customBaseUrl, setCustomBaseUrl] = useState(""); |
| | 97 | // Variable selections for the currently selected server, keyed by variable name. |
| | 98 | const [variableSelections, setVariableSelections] = useState<Record<string, string>>({}); |
| 83 | const identity = useSourceIdentity({ | 99 | const identity = useSourceIdentity({ |
| 84 | fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", | 100 | fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", |
| 85 | fallbackNamespace: props.initialNamespace, | 101 | fallbackNamespace: props.initialNamespace, |
| @@ -118,7 +134,35 @@ export default function AddOpenApiSource(props: { · 32 unmodified lines |
| 118 | | 134 | |
| 119 | // ---- Derived state ---- | 135 | // ---- Derived state ---- |
| 120 | | 136 | |
| 121 | const servers = (preview?.servers ?? []) as Array<{ url?: string }>; | 137 | const servers: readonly ServerInfo[] = preview?.servers ?? []; |
| | 138 | const selectedServer: ServerInfo | null = |
| | 139 | selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null; |
| | 140 | |
| | 141 | const serverVariables: Record<string, ServerVariable> = selectedServer |
| | 142 | ? Option.getOrElse( |
| | 143 | selectedServer.variables, |
| | 144 | () => ({}) as Record<string, ServerVariable>, |
| | 145 | ) |
| | 146 | : {}; |
| | 147 | const serverVariableEntries: Array<[string, ServerVariable]> = |
| | 148 | Object.entries(serverVariables); |
| | 149 | |
| | 150 | const resolvedBaseUrl = |
| | 151 | selectedServer !== null |
| | 152 | ? substituteUrlVariables(selectedServer.url, variableSelections) |
| | 153 | : customBaseUrl.trim(); |
| | 154 | |
| | 155 | // Helper used by analyze + server selection: build a default selection map |
| | 156 | // from a server's variable defaults. |
| | 157 | const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => { |
| | 158 | const vars: Record<string, ServerVariable> = Option.getOrElse( |
| | 159 | server.variables, |
| | 160 | () => ({}) as Record<string, ServerVariable>, |
| | 161 | ); |
| | 162 | const out: Record<string, string> = {}; |
| | 163 | for (const [name, v] of Object.entries(vars)) out[name] = v.default; |
| | 164 | return out; |
| | 165 | }; |
| 122 | | 166 | |
| 123 | // Derive a favicon URL from the spec URL (if the user entered one — raw | 167 | // Derive a favicon URL from the spec URL (if the user entered one — raw |
| 124 | // JSON/YAML content will fail URL parsing and yield null). Uses Google's | 168 | // JSON/YAML content will fail URL parsing and yield null). Uses Google's |
| @@ -155,7 +199,7 @@ export default function AddOpenApiSource(props: { · 30 unmodified lines |
| 155 | | 199 | |
| 156 | const canAdd = | 200 | const canAdd = |
| 157 | preview !== null && | 201 | preview !== null && |
| 158 | baseUrl.trim().length > 0 && | 202 | resolvedBaseUrl.length > 0 && |
| 159 | (customHeaders.length === 0 || customHeadersValid); | 203 | (customHeaders.length === 0 || customHeadersValid); |
| 160 | | 204 | |
| 161 | // ---- Handlers ---- | 205 | // ---- Handlers ---- |
| @@ -171,8 +215,16 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines |
| 171 | }); | 215 | }); |
| 172 | setPreview(result); | 216 | setPreview(result); |
| 173 | | 217 | |
| 174 | const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url; | 218 | const firstServer = result.servers[0]; |
| 175 | if (firstUrl) setBaseUrl(firstUrl); | 219 | if (firstServer) { |
| | 220 | setSelectedServerIndex(0); |
| | 221 | setVariableSelections(defaultSelectionsFor(firstServer)); |
| | 222 | setCustomBaseUrl(""); |
| | 223 | } else { |
| | 224 | setSelectedServerIndex(-1); |
| | 225 | setVariableSelections({}); |
| | 226 | setCustomBaseUrl(""); |
| | 227 | } |
| 176 | | 228 | |
| 177 | const firstPreset = result.headerPresets[0]; | 229 | const firstPreset = result.headerPresets[0]; |
| 178 | if (firstPreset) { | 230 | if (firstPreset) { |
| @@ -228,7 +280,7 @@ export default function AddOpenApiSource(props: { · 49 unmodified lines |
| 228 | spec: specUrl, | 280 | spec: specUrl, |
| 229 | name: identity.name.trim() || undefined, | 281 | name: identity.name.trim() || undefined, |
| 230 | namespace: slugifyNamespace(identity.namespace) || undefined, | 282 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 231 | baseUrl: baseUrl.trim() || undefined, | 283 | baseUrl: resolvedBaseUrl || undefined, |
| 232 | ...(hasHeaders ? { headers: allHeaders } : {}), | 284 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 233 | }, | 285 | }, |
| 234 | }); | 286 | }); |
| @@ -259,7 +311,9 @@ export default function AddOpenApiSource(props: { · 24 unmodified lines |
| 259 | setSpecUrl((e.target as HTMLTextAreaElement).value); | 311 | setSpecUrl((e.target as HTMLTextAreaElement).value); |
| 260 | if (preview) { | 312 | if (preview) { |
| 261 | setPreview(null); | 313 | setPreview(null); |
| 262 | setBaseUrl(""); | 314 | setSelectedServerIndex(-1); |
| | 315 | setCustomBaseUrl(""); |
| | 316 | setVariableSelections({}); |
| 263 | setCustomHeaders([]); | 317 | setCustomHeaders([]); |
| 264 | setSelectedStrategy(-1); | 318 | setSelectedStrategy(-1); |
| 265 | } | 319 | } |
| @@ -337,45 +391,125 @@ export default function AddOpenApiSource(props: { · 71 unmodified lines |
| 337 | <CardStack> | 391 | <CardStack> |
| 338 | <CardStackContent className="border-t-0"> | 392 | <CardStackContent className="border-t-0"> |
| 339 | <CardStackEntryField label="Base URL"> | 393 | <CardStackEntryField label="Base URL"> |
| 340 | {servers.length > 1 ? ( | 394 | {servers.length >= 1 && ( |
| 341 | <div className="space-y-2"> | 395 | <RadioGroup |
| 342 | <RadioGroup value={baseUrl} onValueChange={setBaseUrl} className="gap-1.5"> | 396 | value={String(selectedServerIndex)} |
| 343 | {servers.map((s, i) => { | 397 | onValueChange={(value) => { |
| 344 | const url = s.url ?? ""; | 398 | const idx = Number(value); |
| 345 | return ( | 399 | setSelectedServerIndex(idx); |
| 346 | <Label | 400 | if (idx >= 0) { |
| 347 | key={i} | 401 | const s = servers[idx]; |
| 348 | className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${ | 402 | if (s) setVariableSelections(defaultSelectionsFor(s)); |
| 349 | baseUrl === url | 403 | } else { |
| 350 | ? "border-primary/50 bg-primary/[0.03]" | 404 | setVariableSelections({}); |
| 351 | : "border-border hover:bg-accent/50" | 405 | } |
| 352 | }`} | 406 | }} |
| 353 | > | 407 | className="gap-1.5" |
| 354 | <RadioGroupItem value={url} /> | 408 | > |
| 355 | <span className="font-mono text-xs text-foreground truncate"> | 409 | {servers.map((s, i) => ( |
| 356 | {url} | 410 | <Label |
| 357 | </span> | 411 | key={i} |
| 358 | </Label> | 412 | className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${ |
| 359 | ); | 413 | selectedServerIndex === i |
| 360 | })} | 414 | ? "border-primary/50 bg-primary/[0.03]" |
| 361 | </RadioGroup> | 415 | : "border-border hover:bg-accent/50" |
| 362 | <Input | 416 | }`} |
| 363 | value={baseUrl} | 417 | > |
| 364 | onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)} | 418 | <RadioGroupItem value={String(i)} className="mt-0.5" /> |
| 365 | placeholder="Or enter a custom URL…" | 419 | <div className="min-w-0 flex-1"> |
| 366 | className="font-mono text-sm" | 420 | <div className="font-mono text-xs text-foreground truncate"> |
| 367 | /> | 421 | {s.url} |
| | 422 | </div> |
| | 423 | {Option.isSome(s.description) && ( |
| | 424 | <div className="mt-0.5 text-[10px] text-muted-foreground"> |
| | 425 | {s.description.value} |
| | 426 | </div> |
| | 427 | )} |
| | 428 | </div> |
| | 429 | </Label> |
| | 430 | ))} |
| | 431 | <Label |
| | 432 | className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${ |
| | 433 | selectedServerIndex === -1 |
| | 434 | ? "border-primary/50 bg-primary/[0.03]" |
| | 435 | : "border-border hover:bg-accent/50" |
| | 436 | }`} |
| | 437 | > |
| | 438 | <RadioGroupItem value="-1" /> |
| | 439 | <span className="text-xs font-medium text-foreground">Custom</span> |
| | 440 | </Label> |
| | 441 | </RadioGroup> |
| | 442 | )} |
| | 443 | |
| | 444 | {/* Per-variable pickers for the selected server */} |
| | 445 | {selectedServer && serverVariableEntries.length > 0 && ( |
| | 446 | <div className="mt-2 space-y-2 rounded-lg border border-border/60 bg-muted/20 p-2.5"> |
| | 447 | {serverVariableEntries.map(([name, variable]) => { |
| | 448 | const enumValues: readonly string[] = Option.getOrElse( |
| | 449 | variable.enum, |
| | 450 | () => [] as readonly string[], |
| | 451 | ); |
| | 452 | const current = variableSelections[name] ?? variable.default; |
| | 453 | return ( |
| | 454 | <div key={name} className="space-y-1"> |
| | 455 | <div className="flex items-baseline justify-between gap-2"> |
| | 456 | <Label className="font-mono text-[11px] text-foreground"> |
| | 457 | {`{${name}}`} |
| | 458 | </Label> |
| | 459 | {Option.isSome(variable.description) && ( |
| | 460 | <span className="text-[10px] text-muted-foreground truncate"> |
| | 461 | {variable.description.value} |
| | 462 | </span> |
| | 463 | )} |
| | 464 | </div> |
| | 465 | {enumValues.length > 0 ? ( |
| | 466 | <NativeSelect |
| | 467 | value={current} |
| | 468 | onChange={(e) => |
| | 469 | setVariableSelections((prev) => ({ |
| | 470 | ...prev, |
| | 471 | [name]: (e.target as HTMLSelectElement).value, |
| | 472 | })) |
| | 473 | } |
| | 474 | > |
| | 475 | {enumValues.map((v) => ( |
| | 476 | <NativeSelectOption key={v} value={v}> |
| | 477 | {v} |
| | 478 | </NativeSelectOption> |
| | 479 | ))} |
| | 480 | </NativeSelect> |
| | 481 | ) : ( |
| | 482 | <Input |
| | 483 | value={current} |
| | 484 | onChange={(e) => |
| | 485 | setVariableSelections((prev) => ({ |
| | 486 | ...prev, |
| | 487 | [name]: (e.target as HTMLInputElement).value, |
| | 488 | })) |
| | 489 | } |
| | 490 | className="font-mono text-xs" |
| | 491 | /> |
| | 492 | )} |
| | 493 | </div> |
| | 494 | ); |
| | 495 | })} |
| 368 | </div> | 496 | </div> |
| 369 | ) : ( | 497 | )} |
| | 498 | |
| | 499 | {selectedServerIndex === -1 ? ( |
| 370 | <Input | 500 | <Input |
| 371 | value={baseUrl} | 501 | value={customBaseUrl} |
| 372 | onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)} | 502 | onChange={(e) => setCustomBaseUrl((e.target as HTMLInputElement).value)} |
| 373 | placeholder="https://api.example.com" | 503 | placeholder="https://api.example.com" |
| 374 | className="font-mono text-sm" | 504 | className="font-mono text-sm" |
| 375 | /> | 505 | /> |
| | 506 | ) : ( |
| | 507 | <div className="rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground"> |
| | 508 | {resolvedBaseUrl || "\u00A0"} |
| | 509 | </div> |
| 376 | )} | 510 | )} |
| 377 | | 511 | |
| 378 | {!baseUrl.trim() && ( | 512 | {!resolvedBaseUrl && ( |
| 379 | <p className="text-[11px] text-amber-600 dark:text-amber-400"> | 513 | <p className="text-[11px] text-amber-600 dark:text-amber-400"> |
| 380 | A base URL is required to make requests. | 514 | A base URL is required to make requests. |
| 381 | </p> | 515 | </p> |