History

Add Google service fan-out picker

Rhys Sullivan committed 4 months ago

Commit a1aaefa1 parent 9980d732 files · +415 −69Browse files

Unified · Split

2 files
  1. packages/plugins/google/src/react/AddGoogleSource.tsx +405 −56
  2. packages/plugins/google/src/react/GoogleProductPicker.tsx +10 −13
packages/plugins/google/src/react/AddGoogleSource.tsx+405−56
@@ -1,6 +1,8 @@
1import { useCallback, useMemo, useState } from "react";1import { useCallback, useMemo, useState, type ChangeEvent } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import { Link } from "@tanstack/react-router";
3import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import { CheckIcon, CircleIcon, TriangleAlert } from "lucide-react";
4 6
5import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";7import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import {8import {
@@ -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";
10import { Button } from "@executor-js/react/components/button";12import { Button } from "@executor-js/react/components/button";
13import { FieldLabel } from "@executor-js/react/components/field";
11import { FloatActions } from "@executor-js/react/components/float-actions";14import { FloatActions } from "@executor-js/react/components/float-actions";
15import { Input } from "@executor-js/react/components/input";
12import {16import {
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";
18import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";23import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19 24
20import { addGoogleBundle } from "./atoms";25import { addGoogleBundle, addGoogleServices } from "./atoms";
21import { GoogleProductPicker } from "./GoogleProductPicker";26import { GoogleProductPicker } from "./GoogleProductPicker";
22import {27import {
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";
34import type {
35 GoogleAddServicesInput,
36 GoogleAddServicesResult,
37 GoogleBundleConfig,
38} from "../sdk/plugin";
28 39
29const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";40const 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
37const googleBundleUrls = (48const 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) =>52export type GoogleServiceIdentityOverride = {
42 preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [],53 readonly slug: string;
54 readonly name: string;
55};
56
57export type AddGoogleServicesMutation = (input: {
58 readonly payload: GoogleAddServicesInput;
59 readonly reactivityKeys: typeof integrationWriteKeys;
60}) => Promise<Exit.Exit<GoogleAddServicesResult, unknown>>;
61
62export 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
77export 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
90export 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
110export 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
133export 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
151const googlePresetName = (presetId: string): string =>
152 googleOpenApiPresetById.get(presetId)?.name ?? presetId;
153
154export 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
243const 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
263const 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
281const 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
47export default function AddGoogleSource(props: {293export 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 (isGooglePhotosPreset364 (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 > 0388 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 <OpenApiSourceDetailsFields485 {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 Cancel519 {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 Google522 Connect Google
packages/plugins/google/src/react/GoogleProductPicker.tsx+10−13
@@ -27,12 +27,10 @@ import { isGoogleDiscoveryUrl } from "../sdk/discovery"; · 26 unmodified lines
27// GoogleProductPicker - the "customize your Google connection" surface.27// GoogleProductPicker - the "customize your Google connection" surface.
28//28//
29// A checkable card grid over `googleOpenApiPresets`, grouped/annotated by29// A checkable card grid over `googleOpenApiPresets`, grouped/annotated by
30// `oauthAudience`. The user picks which Google APIs to bundle into the single30// `oauthAudience`. The user picks which Google APIs to add as separate
31// `google` integration; the parent turns the selected discovery URLs into a31// integrations. A "View scopes" panel previews the selected OAuth scopes, and a
32// `{ kind: "googleDiscoveryBundle", urls }` add. A "View scopes" panel previews32// custom-URL escape hatch lets advanced users paste any Google Discovery document
33// the unioned OAuth consent (via `googleOAuthConsentBatches`) BEFORE connecting,33// the preset list doesn't cover.
34// and a custom-URL escape hatch lets advanced users paste any Google Discovery
35// document the preset list doesn't cover.
36// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
37 35
38// Audience groups, ordered from least- to most-privileged. The warning tiers36// Audience groups, ordered from least- to most-privileged. The warning tiers
@@ -219,9 +217,9 @@ export function GoogleProductPicker({ · 180 unmodified lines
219 [],217 [],
220 );218 );
221 219
222 // The "View scopes" preview unions the selected presets' representative220 // The "View scopes" preview groups the selected presets' representative
223 // consent scopes through `googleOAuthConsentBatches`, mirroring the unioned221 // consent scopes so the user can review the requested OAuth surface before
224 // scopes the bundle converter ultimately stores on the integration.222 // adding separate integrations.
225 const consentBatches = useMemo(223 const consentBatches = useMemo(
226 () =>224 () =>
227 googleOAuthConsentBatches(225 googleOAuthConsentBatches(
@@ -244,8 +242,7 @@ export function GoogleProductPicker({ · 16 unmodified lines
244 <div className="space-y-1">242 <div className="space-y-1">
245 <FieldLabel>Customize your Google connection</FieldLabel>243 <FieldLabel>Customize your Google connection</FieldLabel>
246 <p className="text-[11px] text-muted-foreground">244 <p className="text-[11px] text-muted-foreground">
247 Pick the Google APIs to bundle into one connection. They share a single OAuth consent and245 Pick Google APIs to add. Each selected product becomes its own integration.
248 appear as merged tools under one Google integration.
249 </p>246 </p>
250 </div>247 </div>
251 248
@@ -311,8 +308,8 @@ export function GoogleProductPicker({ · 59 unmodified lines
311 <Badge variant="secondary">OAuth</Badge>308 <Badge variant="secondary">OAuth</Badge>
312 </div>309 </div>
313 <p className="text-[11px] text-muted-foreground">310 <p className="text-[11px] text-muted-foreground">
314 The selected Google APIs share one OAuth consent. Review the scopes below, then connect a311 Each selected Google product gets its own OAuth setup. Connect accounts from each
315 Google account from the integration page after adding.312 integration page after adding.
316 </p>313 </p>
317 </div>314 </div>
318 315