Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 0a6820e

Browse files
authored
feat(data-warehouse): render oauth-account-select fields in DynamicSourceSetup (#3401)
1 parent 4609f87 commit 0a6820e

2 files changed

Lines changed: 225 additions & 0 deletions

File tree

packages/api-client/src/posthog-client.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,34 @@ export interface SourceFieldOauthConfig {
446446
requiredScopes?: string;
447447
}
448448

449+
/**
450+
* A picker whose options are the accounts/resources a connected OAuth integration exposes (loaded
451+
* from the `oauth_accounts` endpoint using the integration's server-side token). Used e.g. for a
452+
* GitHub repository or an ad account.
453+
*/
454+
export interface SourceFieldOauthAccountSelectConfig {
455+
type: "oauth-account-select";
456+
name: string;
457+
label: string;
458+
/** Name of the sibling OAuth id field this selector reads its integration id from. */
459+
integrationField: string;
460+
/** Integration kind used to validate the connected integration, e.g. "github". */
461+
integrationKind: string;
462+
placeholder?: string;
463+
caption?: string;
464+
required?: boolean;
465+
}
466+
467+
/** A selectable account/resource an OAuth integration exposes (shared `IntegrationAccount` shape). */
468+
export interface IntegrationAccount {
469+
value: string;
470+
display_name: string;
471+
is_primary: boolean;
472+
badges: string[];
473+
group: string | null;
474+
secondary_text: string | null;
475+
}
476+
449477
export interface SourceFieldSelectConfigOption {
450478
label: string;
451479
value: string;
@@ -480,6 +508,7 @@ export interface SourceFieldUnsupportedConfig {
480508
export type SourceFieldConfig =
481509
| SourceFieldInputConfig
482510
| SourceFieldOauthConfig
511+
| SourceFieldOauthAccountSelectConfig
483512
| SourceFieldSelectConfig
484513
| SourceFieldSwitchGroupConfig
485514
| SourceFieldUnsupportedConfig;
@@ -2253,6 +2282,35 @@ export class PostHogAPIClient {
22532282
return (await response.json()) as Record<string, SourceConfig>;
22542283
}
22552284

2285+
/**
2286+
* List the accounts/resources a connected OAuth integration exposes for a source type (e.g. the
2287+
* repositories a GitHub integration can access), for an `oauth-account-select` field. The backend
2288+
* uses the integration's stored token; the client only passes the integration id. Pass `search`
2289+
* to filter server-side for large lists.
2290+
*/
2291+
async getOauthAccounts(
2292+
projectId: number,
2293+
sourceType: string,
2294+
integrationId: number | string,
2295+
search?: string,
2296+
): Promise<IntegrationAccount[]> {
2297+
const url = new URL(
2298+
`${this.api.baseUrl}/api/environments/${projectId}/external_data_sources/oauth_accounts/`,
2299+
);
2300+
url.searchParams.set("source_type", sourceType);
2301+
url.searchParams.set("integration_id", String(integrationId));
2302+
if (search?.trim()) {
2303+
url.searchParams.set("search", search.trim());
2304+
}
2305+
const path = `/api/environments/${projectId}/external_data_sources/oauth_accounts/`;
2306+
const response = await this.api.fetcher.fetch({ method: "get", url, path });
2307+
if (!response.ok) {
2308+
throw new Error(`Failed to fetch accounts: ${response.statusText}`);
2309+
}
2310+
const data = (await response.json()) as { accounts?: IntegrationAccount[] };
2311+
return data.accounts ?? [];
2312+
}
2313+
22562314
async updateExternalDataSchema(
22572315
projectId: number,
22582316
schemaId: string,

packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type {
2+
IntegrationAccount,
23
SourceConfig,
34
SourceFieldConfig,
45
SourceFieldInputConfig,
6+
SourceFieldOauthAccountSelectConfig,
57
SourceFieldOauthConfig,
68
} from "@posthog/api-client/posthog-client";
79
import { useHostTRPC } from "@posthog/host-router/react";
@@ -98,6 +100,14 @@ function missingRequiredFields(
98100
if (field.required && !values[field.name]) {
99101
missing.push(field.name);
100102
}
103+
} else if (field.type === "oauth-account-select") {
104+
const value = values[field.name];
105+
if (
106+
field.required &&
107+
(typeof value !== "string" || value.trim().length === 0)
108+
) {
109+
missing.push(field.name);
110+
}
101111
} else if (isInputField(field) && field.required) {
102112
const value = values[field.name];
103113
if (typeof value !== "string" || value.trim().length === 0) {
@@ -134,6 +144,11 @@ function buildPayload(
134144
} else if (field.type === "oauth") {
135145
const value = values[field.name];
136146
if (value !== undefined && value !== "") out[field.name] = value;
147+
} else if (field.type === "oauth-account-select") {
148+
const value = values[field.name];
149+
if (typeof value === "string" && value.trim() !== "") {
150+
out[field.name] = value.trim();
151+
}
137152
} else if (isInputField(field)) {
138153
const value = values[field.name];
139154
if (typeof value === "string") out[field.name] = value.trim();
@@ -219,6 +234,7 @@ export function DynamicSourceSetup({
219234
values={values}
220235
setValue={setValue}
221236
providerName={title.replace(/^Connect\s+/i, "")}
237+
sourceType={sourceType}
222238
/>
223239
))}
224240
{hasUnsupportedField && (
@@ -257,11 +273,13 @@ function SourceField({
257273
values,
258274
setValue,
259275
providerName,
276+
sourceType,
260277
}: {
261278
field: SourceFieldConfig;
262279
values: FieldValues;
263280
setValue: (name: string, value: FieldValue) => void;
264281
providerName: string;
282+
sourceType: string;
265283
}) {
266284
if (field.type === "switch-group") {
267285
const enabled = !!values[field.name];
@@ -285,6 +303,7 @@ function SourceField({
285303
values={values}
286304
setValue={setValue}
287305
providerName={providerName}
306+
sourceType={sourceType}
288307
/>
289308
))}
290309
</Flex>
@@ -302,6 +321,18 @@ function SourceField({
302321
);
303322
}
304323

324+
if (field.type === "oauth-account-select") {
325+
return (
326+
<AccountSelectField
327+
field={field}
328+
value={values[field.name]}
329+
setValue={setValue}
330+
sourceType={sourceType}
331+
integrationId={values[field.integrationField]}
332+
/>
333+
);
334+
}
335+
305336
if (field.type === "select") {
306337
const selected = (values[field.name] as string) ?? field.defaultValue ?? "";
307338
const option = field.options.find((o) => o.value === selected);
@@ -328,6 +359,7 @@ function SourceField({
328359
values={values}
329360
setValue={setValue}
330361
providerName={providerName}
362+
sourceType={sourceType}
331363
/>
332364
))}
333365
</Flex>
@@ -484,6 +516,141 @@ function OAuthSourceField({
484516
);
485517
}
486518

519+
/**
520+
* Renders an `oauth-account-select` field: a searchable picker whose options are the accounts/
521+
* resources a connected OAuth integration exposes (e.g. GitHub repositories), fetched from the
522+
* backend using the integration's server-side token (the client only passes the integration id).
523+
* Search is server-side (debounced) so large lists work. Falls back to a free-text input until a
524+
* valid integration id is present in the form.
525+
*/
526+
function AccountSelectField({
527+
field,
528+
value,
529+
setValue,
530+
sourceType,
531+
integrationId,
532+
}: {
533+
field: SourceFieldOauthAccountSelectConfig;
534+
value: FieldValue | undefined;
535+
setValue: (name: string, value: FieldValue) => void;
536+
sourceType: string;
537+
integrationId: FieldValue | undefined;
538+
}) {
539+
const projectId = useAuthStateValue((state) => state.currentProjectId);
540+
const client = useAuthenticatedClient();
541+
const [query, setQuery] = useState(typeof value === "string" ? value : "");
542+
const [accounts, setAccounts] = useState<IntegrationAccount[]>([]);
543+
const [loading, setLoading] = useState(false);
544+
const [open, setOpen] = useState(false);
545+
546+
const hasIntegration =
547+
integrationId !== undefined &&
548+
integrationId !== "" &&
549+
integrationId !== false;
550+
551+
// Reset any prior selection when the backing integration changes. An account
552+
// chosen (or fallback text typed) for one integration must not survive into
553+
// another — otherwise the form could submit an account that was never
554+
// selected for the active integration.
555+
const prevIntegrationId = useRef(integrationId);
556+
useEffect(() => {
557+
if (prevIntegrationId.current === integrationId) return;
558+
prevIntegrationId.current = integrationId;
559+
setQuery("");
560+
setValue(field.name, "");
561+
}, [integrationId, field.name, setValue]);
562+
563+
useEffect(() => {
564+
if (!projectId || !client || !hasIntegration) return;
565+
let cancelled = false;
566+
const handle = setTimeout(async () => {
567+
setLoading(true);
568+
try {
569+
const results = await client.getOauthAccounts(
570+
projectId,
571+
sourceType,
572+
integrationId as number | string,
573+
query,
574+
);
575+
if (!cancelled) setAccounts(results);
576+
} catch {
577+
if (!cancelled) setAccounts([]);
578+
} finally {
579+
if (!cancelled) setLoading(false);
580+
}
581+
}, 300);
582+
return () => {
583+
cancelled = true;
584+
clearTimeout(handle);
585+
};
586+
}, [projectId, client, sourceType, integrationId, hasIntegration, query]);
587+
588+
if (!hasIntegration) {
589+
return (
590+
<Flex direction="column" gap="1">
591+
<Text className="text-gray-12 text-sm">{field.label}</Text>
592+
<TextField.Root
593+
placeholder={field.placeholder || field.label}
594+
value={typeof value === "string" ? value : ""}
595+
onChange={(e) => setValue(field.name, e.target.value)}
596+
/>
597+
{field.caption && (
598+
<Text className="text-[13px] text-gray-11">{field.caption}</Text>
599+
)}
600+
</Flex>
601+
);
602+
}
603+
604+
return (
605+
<Flex direction="column" gap="1">
606+
<Text className="text-gray-12 text-sm">{field.label}</Text>
607+
<TextField.Root
608+
placeholder={field.placeholder || field.label}
609+
value={query}
610+
onChange={(e) => {
611+
// Typing only filters the list; it does not commit a value. The
612+
// submitted account is set solely by picking an option, so editing
613+
// the text after a selection clears it rather than silently mutating
614+
// the underlying (possibly opaque) account id.
615+
setQuery(e.target.value);
616+
setValue(field.name, "");
617+
setOpen(true);
618+
}}
619+
onFocus={() => setOpen(true)}
620+
/>
621+
{open && (loading || accounts.length > 0) && (
622+
<Box className="max-h-48 overflow-y-auto rounded-(--radius-2) border border-border bg-(--color-panel-solid)">
623+
{loading ? (
624+
<Text className="block px-2 py-1 text-[13px] text-gray-11">
625+
Loading…
626+
</Text>
627+
) : (
628+
accounts.map((account) => (
629+
<button
630+
key={account.value}
631+
type="button"
632+
className="block w-full px-2 py-1 text-left text-gray-12 text-sm hover:bg-(--gray-3)"
633+
onClick={() => {
634+
// Commit the account's opaque value to the form, but show the
635+
// human-readable name in the input.
636+
setValue(field.name, account.value);
637+
setQuery(account.display_name);
638+
setOpen(false);
639+
}}
640+
>
641+
{account.display_name}
642+
</button>
643+
))
644+
)}
645+
</Box>
646+
)}
647+
{field.caption && (
648+
<Text className="text-[13px] text-gray-11">{field.caption}</Text>
649+
)}
650+
</Flex>
651+
);
652+
}
653+
487654
function SetupFormContainer({
488655
title,
489656
children,

0 commit comments

Comments
 (0)