| @@ -1,6 +1,8 @@ |
| 1 | import { useCallback, useMemo, useState } from "react"; | 1 | import { useCallback, useMemo, useState, type ChangeEvent } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| | 3 | import { Link } from "@tanstack/react-router"; |
| 3 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| | 5 | import { CheckIcon, CircleIcon, TriangleAlert } from "lucide-react"; |
| 4 | | 6 | |
| 5 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 7 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 6 | import { | 8 | import { |
| @@ -8,23 +10,32 @@ import { · 1 unmodified line |
| 8 | useIntegrationIdentity, | 10 | useIntegrationIdentity, |
| 9 | } from "@executor-js/react/plugins/integration-identity"; | 11 | } from "@executor-js/react/plugins/integration-identity"; |
| 10 | import { Button } from "@executor-js/react/components/button"; | 12 | import { Button } from "@executor-js/react/components/button"; |
| | 13 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 14 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| | 15 | import { Input } from "@executor-js/react/components/input"; |
| 12 | import { | 16 | import { |
| 13 | addIntegrationErrorMessage, | 17 | addIntegrationErrorMessage, |
| | 18 | errorMessageFromExit, |
| 14 | FormErrorAlert, | 19 | FormErrorAlert, |
| 15 | SlugCollisionAlert, | 20 | SlugCollisionAlert, |
| 16 | useSlugAlreadyExists, | 21 | useSlugAlreadyExists, |
| 17 | } from "@executor-js/react/lib/integration-add"; | 22 | } from "@executor-js/react/lib/integration-add"; |
| 18 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; | 23 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; |
| 19 | | 24 | |
| 20 | import { addGoogleBundle } from "./atoms"; | 25 | import { addGoogleBundle, addGoogleServices } from "./atoms"; |
| 21 | import { GoogleProductPicker } from "./GoogleProductPicker"; | 26 | import { GoogleProductPicker } from "./GoogleProductPicker"; |
| 22 | import { | 27 | import { |
| 23 | GOOGLE_PHOTOS_PRESET_ID, | 28 | GOOGLE_PHOTOS_PRESET_ID, |
| 24 | googleOpenApiPresets, | 29 | googleOpenApiPresets, |
| 25 | googlePhotosPresetIds, | 30 | googlePhotosPresetIds, |
| | 31 | googleServiceSlug, |
| 26 | type GoogleOpenApiPreset, | 32 | type GoogleOpenApiPreset, |
| 27 | } from "../sdk/presets"; | 33 | } from "../sdk/presets"; |
| | 34 | import type { |
| | 35 | GoogleAddServicesInput, |
| | 36 | GoogleAddServicesResult, |
| | 37 | GoogleBundleConfig, |
| | 38 | } from "../sdk/plugin"; |
| 28 | | 39 | |
| 29 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | 40 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; |
| 30 | | 41 | |
| @@ -34,14 +45,249 @@ const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set( · 3 unmodified lines |
| 34 | .map((preset: GoogleOpenApiPreset) => preset.id), | 45 | .map((preset: GoogleOpenApiPreset) => preset.id), |
| 35 | ); | 46 | ); |
| 36 | | 47 | |
| 37 | const googleBundleUrls = ( | 48 | const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map( |
| 38 | selectedPresetIds: ReadonlySet<string>, | 49 | googleOpenApiPresets.map((preset: GoogleOpenApiPreset) => [preset.id, preset]), |
| 39 | customUrls: readonly string[], | 50 | ); |
| 40 | ): readonly string[] => { | 51 | |
| 41 | const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) => | 52 | export type GoogleServiceIdentityOverride = { |
| 42 | preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [], | 53 | readonly slug: string; |
| | 54 | readonly name: string; |
| | 55 | }; |
| | 56 | |
| | 57 | export type AddGoogleServicesMutation = (input: { |
| | 58 | readonly payload: GoogleAddServicesInput; |
| | 59 | readonly reactivityKeys: typeof integrationWriteKeys; |
| | 60 | }) => Promise<Exit.Exit<GoogleAddServicesResult, unknown>>; |
| | 61 | |
| | 62 | export const googleAddServicesPayload = (input: { |
| | 63 | readonly presetIds: readonly string[]; |
| | 64 | readonly identityOverride?: GoogleServiceIdentityOverride; |
| | 65 | readonly baseUrl?: string; |
| | 66 | }): GoogleAddServicesInput => { |
| | 67 | const identityOverride = input.presetIds.length === 1 ? input.identityOverride : undefined; |
| | 68 | const services = input.presetIds.map((presetId: string) => ({ |
| | 69 | presetId, |
| | 70 | ...(identityOverride?.slug.trim() ? { slug: identityOverride.slug.trim() } : {}), |
| | 71 | ...(identityOverride?.name.trim() ? { name: identityOverride.name.trim() } : {}), |
| | 72 | })); |
| | 73 | const baseUrl = input.baseUrl?.trim() ?? ""; |
| | 74 | return baseUrl.length > 0 ? { services, baseUrl } : { services }; |
| | 75 | }; |
| | 76 | |
| | 77 | export const submitGoogleServicesSelection = ( |
| | 78 | doAddServices: AddGoogleServicesMutation, |
| | 79 | input: { |
| | 80 | readonly presetIds: readonly string[]; |
| | 81 | readonly identityOverride?: GoogleServiceIdentityOverride; |
| | 82 | readonly baseUrl?: string; |
| | 83 | }, |
| | 84 | ): Promise<Exit.Exit<GoogleAddServicesResult, unknown>> => |
| | 85 | doAddServices({ |
| | 86 | payload: googleAddServicesPayload(input), |
| | 87 | reactivityKeys: integrationWriteKeys, |
| | 88 | }); |
| | 89 | |
| | 90 | export type GoogleServiceResultRow = |
| | 91 | | { |
| | 92 | readonly status: "added"; |
| | 93 | readonly presetId: string; |
| | 94 | readonly slug: string; |
| | 95 | readonly toolCount: number; |
| | 96 | } |
| | 97 | | { |
| | 98 | readonly status: "skipped"; |
| | 99 | readonly presetId: string; |
| | 100 | readonly slug: string; |
| | 101 | readonly reason: "already_exists"; |
| | 102 | } |
| | 103 | | { |
| | 104 | readonly status: "failed"; |
| | 105 | readonly presetId: string; |
| | 106 | readonly slug: string; |
| | 107 | readonly error: string; |
| | 108 | }; |
| | 109 | |
| | 110 | export const googleAddServicesResultRows = ( |
| | 111 | result: GoogleAddServicesResult, |
| | 112 | ): readonly GoogleServiceResultRow[] => [ |
| | 113 | ...result.added.map((entry) => ({ |
| | 114 | status: "added" as const, |
| | 115 | presetId: entry.presetId, |
| | 116 | slug: String(entry.slug), |
| | 117 | toolCount: entry.toolCount, |
| | 118 | })), |
| | 119 | ...result.skipped.map((entry) => ({ |
| | 120 | status: "skipped" as const, |
| | 121 | presetId: entry.presetId, |
| | 122 | slug: String(entry.slug), |
| | 123 | reason: entry.reason, |
| | 124 | })), |
| | 125 | ...result.failed.map((entry) => ({ |
| | 126 | status: "failed" as const, |
| | 127 | presetId: entry.presetId, |
| | 128 | slug: String(entry.slug), |
| | 129 | error: entry.error, |
| | 130 | })), |
| | 131 | ]; |
| | 132 | |
| | 133 | export const mergeGoogleAddServicesResult = ( |
| | 134 | previous: GoogleAddServicesResult, |
| | 135 | next: GoogleAddServicesResult, |
| | 136 | ): GoogleAddServicesResult => { |
| | 137 | const nextPresetIds = new Set(googleAddServicesResultRows(next).map((row) => row.presetId)); |
| | 138 | return { |
| | 139 | added: [...previous.added.filter((entry) => !nextPresetIds.has(entry.presetId)), ...next.added], |
| | 140 | skipped: [ |
| | 141 | ...previous.skipped.filter((entry) => !nextPresetIds.has(entry.presetId)), |
| | 142 | ...next.skipped, |
| | 143 | ], |
| | 144 | failed: [ |
| | 145 | ...previous.failed.filter((entry) => !nextPresetIds.has(entry.presetId)), |
| | 146 | ...next.failed, |
| | 147 | ], |
| | 148 | }; |
| | 149 | }; |
| | 150 | |
| | 151 | const googlePresetName = (presetId: string): string => |
| | 152 | googleOpenApiPresetById.get(presetId)?.name ?? presetId; |
| | 153 | |
| | 154 | export function GoogleServiceResultPanel(props: { |
| | 155 | readonly result: GoogleAddServicesResult; |
| | 156 | readonly retryingPresetId: string | null; |
| | 157 | readonly onRetry: (presetId: string) => void | Promise<void>; |
| | 158 | }) { |
| | 159 | const rows = googleAddServicesResultRows(props.result); |
| | 160 | if (rows.length === 0) return null; |
| | 161 | |
| | 162 | return ( |
| | 163 | <section className="space-y-3 rounded-lg border border-border bg-muted/10 px-3 py-3"> |
| | 164 | <div> |
| | 165 | <h2 className="text-sm font-medium text-foreground">Google products</h2> |
| | 166 | <p className="text-[11px] text-muted-foreground"> |
| | 167 | Each selected product is added as its own integration. |
| | 168 | </p> |
| | 169 | </div> |
| | 170 | <ul className="space-y-2"> |
| | 171 | {rows.map((row: GoogleServiceResultRow) => { |
| | 172 | const presetName = googlePresetName(row.presetId); |
| | 173 | return ( |
| | 174 | <li |
| | 175 | key={`${row.status}:${row.presetId}`} |
| | 176 | className="flex items-start gap-2 rounded-md border border-border bg-background px-2.5 py-2" |
| | 177 | > |
| | 178 | <span |
| | 179 | className={ |
| | 180 | row.status === "added" |
| | 181 | ? "mt-0.5 text-emerald-600" |
| | 182 | : row.status === "skipped" |
| | 183 | ? "mt-0.5 text-muted-foreground" |
| | 184 | : "mt-0.5 text-destructive" |
| | 185 | } |
| | 186 | > |
| | 187 | {row.status === "added" ? ( |
| | 188 | <CheckIcon className="size-3.5" /> |
| | 189 | ) : row.status === "skipped" ? ( |
| | 190 | <CircleIcon className="size-3.5" /> |
| | 191 | ) : ( |
| | 192 | <TriangleAlert className="size-3.5" /> |
| | 193 | )} |
| | 194 | </span> |
| | 195 | <div className="min-w-0 flex-1 space-y-0.5"> |
| | 196 | <div className="flex min-w-0 items-center gap-2"> |
| | 197 | <span className="truncate text-sm font-medium text-foreground">{presetName}</span> |
| | 198 | <span className="shrink-0 text-[11px] text-muted-foreground"> |
| | 199 | {row.status === "added" |
| | 200 | ? "Added" |
| | 201 | : row.status === "skipped" |
| | 202 | ? "Already exists" |
| | 203 | : "Failed"} |
| | 204 | </span> |
| | 205 | </div> |
| | 206 | {row.status === "added" ? ( |
| | 207 | <p className="text-[11px] text-muted-foreground"> |
| | 208 | {row.toolCount} tool{row.toolCount === 1 ? "" : "s"} added. |
| | 209 | </p> |
| | 210 | ) : row.status === "skipped" ? ( |
| | 211 | <p className="text-[11px] text-muted-foreground"> |
| | 212 | This integration already exists. |
| | 213 | </p> |
| | 214 | ) : ( |
| | 215 | <p className="text-[11px] text-destructive">{row.error}</p> |
| | 216 | )} |
| | 217 | </div> |
| | 218 | {row.status === "failed" ? ( |
| | 219 | <Button |
| | 220 | type="button" |
| | 221 | variant="outline" |
| | 222 | size="xs" |
| | 223 | loading={props.retryingPresetId === row.presetId} |
| | 224 | onClick={() => void props.onRetry(row.presetId)} |
| | 225 | > |
| | 226 | Retry |
| | 227 | </Button> |
| | 228 | ) : ( |
| | 229 | <Button variant="ghost" size="xs" asChild> |
| | 230 | <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: row.slug }}> |
| | 231 | Open |
| | 232 | </Link> |
| | 233 | </Button> |
| | 234 | )} |
| | 235 | </li> |
| | 236 | ); |
| | 237 | })} |
| | 238 | </ul> |
| | 239 | </section> |
| 43 | ); | 240 | ); |
| 44 | return [...new Set([...fromPresets, ...customUrls])]; | 241 | } |
| | 242 | |
| | 243 | const BaseUrlSettings = (props: { |
| | 244 | readonly baseUrl: string; |
| | 245 | readonly onBaseUrlChange: (value: string) => void; |
| | 246 | }) => ( |
| | 247 | <section className="space-y-2 rounded-lg border border-border bg-muted/10 px-3 py-3"> |
| | 248 | <div className="space-y-1"> |
| | 249 | <FieldLabel>Google product settings</FieldLabel> |
| | 250 | <p className="text-[11px] text-muted-foreground"> |
| | 251 | Selected products keep their preset names and namespaces. |
| | 252 | </p> |
| | 253 | </div> |
| | 254 | <Input |
| | 255 | value={props.baseUrl} |
| | 256 | onChange={(event: ChangeEvent<HTMLInputElement>) => props.onBaseUrlChange(event.target.value)} |
| | 257 | placeholder="Base URL override (optional)" |
| | 258 | className="font-mono text-sm" |
| | 259 | /> |
| | 260 | </section> |
| | 261 | ); |
| | 262 | |
| | 263 | const CustomGoogleBundleResult = (props: { readonly slug: string }) => ( |
| | 264 | <section className="rounded-lg border border-border bg-muted/10 px-3 py-3"> |
| | 265 | <div className="flex items-center justify-between gap-3"> |
| | 266 | <div className="min-w-0"> |
| | 267 | <h2 className="text-sm font-medium text-foreground">Custom Discovery URLs</h2> |
| | 268 | <p className="text-[11px] text-muted-foreground"> |
| | 269 | Custom URLs were added through the legacy Google bundle path. |
| | 270 | </p> |
| | 271 | </div> |
| | 272 | <Button variant="ghost" size="xs" asChild> |
| | 273 | <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: props.slug }}> |
| | 274 | Open |
| | 275 | </Link> |
| | 276 | </Button> |
| | 277 | </div> |
| | 278 | </section> |
| | 279 | ); |
| | 280 | |
| | 281 | const googleBundlePayload = (config: GoogleBundleConfig): GoogleBundleConfig => { |
| | 282 | const baseUrl = config.baseUrl?.trim() ?? ""; |
| | 283 | const description = config.description?.trim() ?? ""; |
| | 284 | return { |
| | 285 | urls: config.urls, |
| | 286 | ...(config.slug !== undefined ? { slug: config.slug } : {}), |
| | 287 | ...(config.name !== undefined ? { name: config.name } : {}), |
| | 288 | ...(description.length > 0 ? { description } : {}), |
| | 289 | ...(baseUrl.length > 0 ? { baseUrl } : {}), |
| | 290 | }; |
| 45 | }; | 291 | }; |
| 46 | | 292 | |
| 47 | export default function AddGoogleSource(props: { | 293 | export default function AddGoogleSource(props: { |
| @@ -58,19 +304,31 @@ export default function AddGoogleSource(props: { · 10 unmodified lines |
| 58 | const [baseUrl, setBaseUrl] = useState(""); | 304 | const [baseUrl, setBaseUrl] = useState(""); |
| 59 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | 305 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); |
| 60 | const [adding, setAdding] = useState(false); | 306 | const [adding, setAdding] = useState(false); |
| | 307 | const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null); |
| 61 | const [addError, setAddError] = useState<string | null>(null); | 308 | const [addError, setAddError] = useState<string | null>(null); |
| | 309 | const [servicesResult, setServicesResult] = useState<GoogleAddServicesResult | null>(null); |
| | 310 | const [customBundleSlug, setCustomBundleSlug] = useState<string | null>(null); |
| | 311 | |
| | 312 | const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]); |
| | 313 | const singleSelectedPreset = |
| | 314 | selectedIds.length === 1 ? googleOpenApiPresetById.get(selectedIds[0]!) : undefined; |
| | 315 | const usesCustomBundleFallback = customDiscoveryUrls.length > 0; |
| 62 | | 316 | |
| 63 | const identity = useIntegrationIdentity({ | 317 | const identity = useIntegrationIdentity({ |
| 64 | fallbackName: isGooglePhotosPreset ? "Google Photos" : "Google", | 318 | fallbackName: usesCustomBundleFallback |
| | 319 | ? "Custom Google APIs" |
| | 320 | : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google")), |
| 65 | fallbackNamespace: | 321 | fallbackNamespace: |
| 66 | props.initialNamespace ?? (isGooglePhotosPreset ? "google_photos" : "google"), | 322 | props.initialNamespace ?? |
| | 323 | (usesCustomBundleFallback |
| | 324 | ? "google_custom" |
| | 325 | : singleSelectedPreset |
| | 326 | ? googleServiceSlug(singleSelectedPreset.id) |
| | 327 | : isGooglePhotosPreset |
| | 328 | ? "google_photos" |
| | 329 | : "google"), |
| 67 | }); | 330 | }); |
| 68 | | 331 | |
| 69 | const bundleDiscoveryUrls = useMemo( | | |
| 70 | () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls), | | |
| 71 | [selectedPresetIds, customDiscoveryUrls], | | |
| 72 | ); | | |
| 73 | | | |
| 74 | const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { | 332 | const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { |
| 75 | setSelectedPresetIds((current: ReadonlySet<string>) => { | 333 | setSelectedPresetIds((current: ReadonlySet<string>) => { |
| 76 | const next = new Set(current); | 334 | const next = new Set(current); |
| @@ -92,41 +350,119 @@ export default function AddGoogleSource(props: { · 15 unmodified lines |
| 92 | ); | 350 | ); |
| 93 | }, []); | 351 | }, []); |
| 94 | | 352 | |
| 95 | const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); | 353 | const doAddServices = useAtomSet(addGoogleServices, { mode: "promiseExit" }); |
| | 354 | const doAddBundle = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); |
| 96 | | 355 | |
| 97 | const resolvedSourceId = | 356 | const resolvedSourceId = slugifyNamespace(identity.namespace) || "google_custom"; |
| 98 | slugifyNamespace(identity.namespace) || (isGooglePhotosPreset ? "google_photos" : "google"); | | |
| 99 | const resolvedDisplayName = | 357 | const resolvedDisplayName = |
| 100 | identity.name.trim() || (isGooglePhotosPreset ? "Google Photos" : "Google"); | 358 | identity.name.trim() || |
| | 359 | (usesCustomBundleFallback |
| | 360 | ? "Custom Google APIs" |
| | 361 | : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google"))); |
| 101 | const resolvedDescription = | 362 | const resolvedDescription = |
| 102 | descriptionDraft ?? | 363 | descriptionDraft ?? |
| 103 | (isGooglePhotosPreset | 364 | (usesCustomBundleFallback |
| 104 | ? "Google Photos albums, uploads, app-created media, and selected picker media." | 365 | ? "Custom Google APIs." |
| 105 | : "Google APIs"); | 366 | : isGooglePhotosPreset |
| 106 | const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); | 367 | ? "Google Photos albums, uploads, app-created media, and selected picker media." |
| 107 | const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists; | 368 | : "Google APIs"); |
| | 369 | const customSlugAlreadyExists = useSlugAlreadyExists( |
| | 370 | usesCustomBundleFallback ? resolvedSourceId : "", |
| | 371 | ); |
| | 372 | const identityOverride = |
| | 373 | selectedIds.length === 1 && !usesCustomBundleFallback |
| | 374 | ? { slug: resolvedSourceId, name: resolvedDisplayName } |
| | 375 | : undefined; |
| | 376 | const canAdd = |
| | 377 | (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && |
| | 378 | !customSlugAlreadyExists && |
| | 379 | !adding; |
| 108 | | 380 | |
| 109 | const handleAdd = async () => { | 381 | const addCustomDiscoveryBundle = async (): Promise<boolean> => { |
| 110 | setAdding(true); | 382 | if (customDiscoveryUrls.length === 0) return true; |
| 111 | setAddError(null); | 383 | const exit = await doAddBundle({ |
| 112 | const exit = await doAdd({ | 384 | payload: googleBundlePayload({ |
| 113 | payload: { | 385 | urls: [...customDiscoveryUrls], |
| 114 | urls: [...bundleDiscoveryUrls], | | |
| 115 | slug: resolvedSourceId, | 386 | slug: resolvedSourceId, |
| 116 | name: resolvedDisplayName, | 387 | name: resolvedDisplayName, |
| 117 | ...(resolvedDescription.trim().length > 0 | 388 | description: resolvedDescription, |
| 118 | ? { description: resolvedDescription.trim() } | 389 | baseUrl, |
| 119 | : {}), | 390 | }), |
| 120 | ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}), | | |
| 121 | }, | | |
| 122 | reactivityKeys: integrationWriteKeys, | 391 | reactivityKeys: integrationWriteKeys, |
| 123 | }); | 392 | }); |
| 124 | if (Exit.isFailure(exit)) { | 393 | if (Exit.isFailure(exit)) { |
| 125 | setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google")); | 394 | setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google")); |
| 126 | setAdding(false); | 395 | return false; |
| | 396 | } |
| | 397 | setCustomBundleSlug(String(exit.value.slug)); |
| | 398 | return true; |
| | 399 | }; |
| | 400 | |
| | 401 | const handleAdd = async () => { |
| | 402 | setAdding(true); |
| | 403 | setAddError(null); |
| | 404 | setServicesResult(null); |
| | 405 | setCustomBundleSlug(null); |
| | 406 | if (selectedIds.length > 0) { |
| | 407 | const exit = await submitGoogleServicesSelection(doAddServices, { |
| | 408 | presetIds: selectedIds, |
| | 409 | ...(identityOverride ? { identityOverride } : {}), |
| | 410 | baseUrl, |
| | 411 | }); |
| | 412 | if (Exit.isFailure(exit)) { |
| | 413 | setAddError(errorMessageFromExit(exit, "Failed to add Google services")); |
| | 414 | setAdding(false); |
| | 415 | return; |
| | 416 | } |
| | 417 | setServicesResult(exit.value); |
| | 418 | } |
| | 419 | await addCustomDiscoveryBundle(); |
| | 420 | setAdding(false); |
| | 421 | }; |
| | 422 | |
| | 423 | const handleRetry = async (presetId: string) => { |
| | 424 | setRetryingPresetId(presetId); |
| | 425 | setAddError(null); |
| | 426 | const retryIdentityOverride = |
| | 427 | identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId |
| | 428 | ? identityOverride |
| | 429 | : undefined; |
| | 430 | const exit = await submitGoogleServicesSelection(doAddServices, { |
| | 431 | presetIds: [presetId], |
| | 432 | ...(retryIdentityOverride ? { identityOverride: retryIdentityOverride } : {}), |
| | 433 | baseUrl, |
| | 434 | }); |
| | 435 | if (Exit.isFailure(exit)) { |
| | 436 | setAddError(errorMessageFromExit(exit, "Failed to add Google service")); |
| | 437 | setRetryingPresetId(null); |
| 127 | return; | 438 | return; |
| 128 | } | 439 | } |
| 129 | props.onComplete(String(exit.value.slug)); | 440 | setServicesResult((current) => |
| | 441 | current ? mergeGoogleAddServicesResult(current, exit.value) : exit.value, |
| | 442 | ); |
| | 443 | setRetryingPresetId(null); |
| | 444 | }; |
| | 445 | |
| | 446 | const showIdentityDetails = selectedIds.length === 1 || usesCustomBundleFallback; |
| | 447 | const detailTitle = usesCustomBundleFallback |
| | 448 | ? "Custom Google Discovery URLs" |
| | 449 | : (singleSelectedPreset?.name ?? "Google"); |
| | 450 | const detailSubtitle = usesCustomBundleFallback |
| | 451 | ? selectedIds.length > 0 |
| | 452 | ? `${customDiscoveryUrls.length} custom URL${ |
| | 453 | customDiscoveryUrls.length === 1 ? "" : "s" |
| | 454 | } added through the legacy bundle path. Selected products keep preset names.` |
| | 455 | : `${customDiscoveryUrls.length} custom URL${ |
| | 456 | customDiscoveryUrls.length === 1 ? "" : "s" |
| | 457 | } added through the legacy bundle path.` |
| | 458 | : "This product is added as its own integration."; |
| | 459 | |
| | 460 | const dismiss = () => { |
| | 461 | if (servicesResult || customBundleSlug) { |
| | 462 | props.onComplete(); |
| | 463 | return; |
| | 464 | } |
| | 465 | props.onCancel(); |
| 130 | }; | 466 | }; |
| 131 | | 467 | |
| 132 | return ( | 468 | return ( |
| @@ -134,7 +470,7 @@ export default function AddGoogleSource(props: { · 1 unmodified line |
| 134 | <div> | 470 | <div> |
| 135 | <h1 className="text-xl font-semibold text-foreground">Add Google integration</h1> | 471 | <h1 className="text-xl font-semibold text-foreground">Add Google integration</h1> |
| 136 | <p className="mt-1 text-[13px] text-muted-foreground"> | 472 | <p className="mt-1 text-[13px] text-muted-foreground"> |
| 137 | Bundle Google APIs into one integration with a shared OAuth consent. | 473 | Each selected product is added as its own integration. |
| 138 | </p> | 474 | </p> |
| 139 | </div> | 475 | </div> |
| 140 | | 476 | |
| @@ -146,28 +482,41 @@ export default function AddGoogleSource(props: { · 5 unmodified lines |
| 146 | onRemoveCustomUrl={removeCustomDiscoveryUrl} | 482 | onRemoveCustomUrl={removeCustomDiscoveryUrl} |
| 147 | /> | 483 | /> |
| 148 | | 484 | |
| 149 | <OpenApiSourceDetailsFields | 485 | {showIdentityDetails ? ( |
| 150 | title="Google" | 486 | <OpenApiSourceDetailsFields |
| 151 | subtitle={`${bundleDiscoveryUrls.length} Google API${ | 487 | title={detailTitle} |
| 152 | bundleDiscoveryUrls.length !== 1 ? "s" : "" | 488 | subtitle={detailSubtitle} |
| 153 | } · one shared OAuth consent`} | 489 | identity={identity} |
| 154 | identity={identity} | 490 | {...(usesCustomBundleFallback |
| 155 | description={resolvedDescription} | 491 | ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } |
| 156 | onDescriptionChange={setDescriptionDraft} | 492 | : {})} |
| 157 | baseUrl={baseUrl} | 493 | baseUrl={baseUrl} |
| 158 | onBaseUrlChange={setBaseUrl} | 494 | onBaseUrlChange={setBaseUrl} |
| 159 | baseUrlLabel="Base URL override (optional)" | 495 | baseUrlLabel="Base URL override (optional)" |
| 160 | faviconIcon={GOOGLE_BUNDLE_FAVICON} | 496 | faviconIcon={GOOGLE_BUNDLE_FAVICON} |
| 161 | faviconUrl={baseUrl} | 497 | faviconUrl={baseUrl} |
| 162 | /> | 498 | /> |
| | 499 | ) : ( |
| | 500 | <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} /> |
| | 501 | )} |
| 163 | | 502 | |
| 164 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | 503 | {customSlugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} |
| 165 | | 504 | |
| 166 | {addError && <FormErrorAlert message={addError} />} | 505 | {addError && <FormErrorAlert message={addError} />} |
| 167 | | 506 | |
| | 507 | {servicesResult && ( |
| | 508 | <GoogleServiceResultPanel |
| | 509 | result={servicesResult} |
| | 510 | retryingPresetId={retryingPresetId} |
| | 511 | onRetry={handleRetry} |
| | 512 | /> |
| | 513 | )} |
| | 514 | |
| | 515 | {customBundleSlug && <CustomGoogleBundleResult slug={customBundleSlug} />} |
| | 516 | |
| 168 | <FloatActions> | 517 | <FloatActions> |
| 169 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | 518 | <Button variant="ghost" onClick={dismiss} disabled={adding || retryingPresetId !== null}> |
| 170 | Cancel | 519 | {servicesResult || customBundleSlug ? "Done" : "Cancel"} |
| 171 | </Button> | 520 | </Button> |
| 172 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | 521 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> |
| 173 | Connect Google | 522 | Connect Google |