diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 9d74ce0d2b..ef5223de55 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -250,6 +250,7 @@ export interface SignalSourceConfig { | "llm_analytics" | "github" | "linear" + | "jira" | "zendesk" | "conversations" | "error_tracking" @@ -402,6 +403,86 @@ export interface ExternalDataSource { schemas?: ExternalDataSourceSchema[] | string; } +/** + * Field-config variants for an external data source's connect form, as served + * by the `external_data_sources/wizard/` endpoint. Mirrors PostHog Cloud's + * `SourceFieldConfig` union (`posthog/schema.py`). The backend is the single + * source of truth for which credential fields a source needs, so forms can be + * rendered generically instead of hardcoded per source. + */ +export interface SourceFieldInputConfig { + type: + | "text" + | "email" + | "search" + | "url" + | "password" + | "time" + | "number" + | "textarea"; + name: string; + label: string; + required: boolean; + placeholder?: string; + caption?: string | null; + /** Redacted from API responses; render as a password field. */ + secret?: boolean; +} + +export interface SourceFieldOauthConfig { + type: "oauth"; + name: string; + label: string; + kind: string; + required: boolean; + requiredScopes?: string; +} + +export interface SourceFieldSelectConfigOption { + label: string; + value: string; + fields?: SourceFieldConfig[]; +} + +export interface SourceFieldSelectConfig { + type: "select"; + name: string; + label: string; + required: boolean; + defaultValue?: string; + options: SourceFieldSelectConfigOption[]; +} + +export interface SourceFieldSwitchGroupConfig { + type: "switch-group"; + name: string; + label: string; + caption?: string; + default?: boolean; + fields: SourceFieldConfig[]; +} + +/** Field types the generic renderer does not (yet) handle inline. */ +export interface SourceFieldUnsupportedConfig { + type: "ssh-tunnel" | "file-upload"; + name: string; + label: string; +} + +export type SourceFieldConfig = + | SourceFieldInputConfig + | SourceFieldOauthConfig + | SourceFieldSelectConfig + | SourceFieldSwitchGroupConfig + | SourceFieldUnsupportedConfig; + +export interface SourceConfig { + name: string; + label?: string; + caption?: string; + fields: SourceFieldConfig[]; +} + export interface FolderInstructionsUser { id?: number; uuid?: string; @@ -2108,6 +2189,30 @@ export class PostHogAPIClient { return response.data as unknown as ExternalDataSource; } + /** + * Fetch the connect-form field schema for external data source types from the + * warehouse wizard endpoint. Pass `sourceType` (e.g. `"Jira"`) to scope to one + * source; omit to fetch every source's config. Returns a map keyed by the + * capitalized source type string. + */ + async getExternalDataSourceConfigs( + projectId: number, + sourceType?: string, + ): Promise> { + const url = new URL( + `${this.api.baseUrl}/api/environments/${projectId}/external_data_sources/wizard/`, + ); + if (sourceType) { + url.searchParams.set("source_type", sourceType); + } + const path = `/api/environments/${projectId}/external_data_sources/wizard/`; + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + if (!response.ok) { + throw new Error(`Failed to fetch source configs: ${response.statusText}`); + } + return (await response.json()) as Record; + } + async updateExternalDataSchema( projectId: number, schemaId: string, diff --git a/packages/core/src/inbox/dataSourceService.ts b/packages/core/src/inbox/dataSourceService.ts index 4a3da86886..4b82407abc 100644 --- a/packages/core/src/inbox/dataSourceService.ts +++ b/packages/core/src/inbox/dataSourceService.ts @@ -2,11 +2,17 @@ import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; import { inject, injectable } from "inversify"; import { LINEAR_OAUTH_FLOW, type LinearOAuthFlow } from "./identifiers"; -export type DataSourceType = "github" | "linear" | "zendesk" | "pganalyze"; +export type DataSourceType = + | "github" + | "linear" + | "jira" + | "zendesk" + | "pganalyze"; const REQUIRED_SCHEMAS: Record = { github: ["issues"], linear: ["issues"], + jira: ["issues"], zendesk: ["tickets"], pganalyze: ["issues", "servers"], }; diff --git a/packages/core/src/inbox/signalSourceService.ts b/packages/core/src/inbox/signalSourceService.ts index aa3f8f583a..b83b43801d 100644 --- a/packages/core/src/inbox/signalSourceService.ts +++ b/packages/core/src/inbox/signalSourceService.ts @@ -12,6 +12,7 @@ export interface SignalSourceValues { error_tracking: boolean; github: boolean; linear: boolean; + jira: boolean; zendesk: boolean; conversations: boolean; pganalyze: boolean; @@ -22,6 +23,7 @@ export type SignalSourceProduct = keyof SignalSourceValues; export type WarehouseSourceProduct = | "github" | "linear" + | "jira" | "zendesk" | "pganalyze"; @@ -45,6 +47,7 @@ const SOURCE_TYPE_MAP: Record< session_replay: "session_analysis_cluster", github: "issue", linear: "issue", + jira: "issue", zendesk: "ticket", conversations: "ticket", pganalyze: "issue", @@ -62,6 +65,7 @@ const DATA_WAREHOUSE_SOURCES: Record< > = { github: { dwSourceType: "Github", requiredTable: "issues" }, linear: { dwSourceType: "Linear", requiredTable: "issues" }, + jira: { dwSourceType: "Jira", requiredTable: "issues" }, zendesk: { dwSourceType: "Zendesk", requiredTable: "tickets" }, pganalyze: { dwSourceType: "PgAnalyze", requiredTable: "issues" }, }; @@ -71,6 +75,7 @@ const ALL_SOURCE_PRODUCTS: SignalSourceProduct[] = [ "error_tracking", "github", "linear", + "jira", "zendesk", "conversations", "pganalyze", @@ -106,6 +111,7 @@ export function computeSourceValues( error_tracking: false, github: false, linear: false, + jira: false, zendesk: false, conversations: false, pganalyze: false, @@ -194,7 +200,7 @@ export class SignalSourceService { } const issuesFullReplication = - (product === "github" || product === "linear") && + (product === "github" || product === "linear" || product === "jira") && dwConfig.requiredTable === "issues"; if (issuesFullReplication) { diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index c2e0144122..2e7fb94c60 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -788,6 +788,7 @@ export interface SignalSourceConnectedProperties { | "signals_scout" | "github" | "linear" + | "jira" | "zendesk" | "conversations" | "pganalyze" diff --git a/packages/shared/src/inbox-types.ts b/packages/shared/src/inbox-types.ts index 578c1b2eb9..091a08ccb4 100644 --- a/packages/shared/src/inbox-types.ts +++ b/packages/shared/src/inbox-types.ts @@ -11,6 +11,7 @@ export type SourceProduct = | "llm_analytics" | "github" | "linear" + | "jira" | "zendesk" | "conversations" | "pganalyze" diff --git a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx index 7f17bb836b..204aeb10a4 100644 --- a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx @@ -3,6 +3,7 @@ import { Button } from "@posthog/quill"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { GitHubRepoPicker } from "@posthog/ui/features/folder-picker/GitHubRepoPicker"; +import { DynamicSourceSetup } from "@posthog/ui/features/inbox/components/DynamicSourceSetup"; import { describeGithubConnectError, useGithubConnect, @@ -16,11 +17,12 @@ import { Box, Flex, Text, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; -type DataSourceType = "github" | "linear" | "zendesk" | "pganalyze"; +type DataSourceType = "github" | "linear" | "jira" | "zendesk" | "pganalyze"; const REQUIRED_SCHEMAS: Record = { github: ["issues"], linear: ["issues"], + jira: ["issues"], zendesk: ["tickets"], pganalyze: ["issues", "servers"], }; @@ -52,6 +54,16 @@ export function DataSourceSetup({ return ; case "linear": return ; + case "jira": + return ( + + ); case "zendesk": return ; case "pganalyze": diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx new file mode 100644 index 0000000000..9cc24b2482 --- /dev/null +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -0,0 +1,360 @@ +import type { + SourceConfig, + SourceFieldConfig, + SourceFieldInputConfig, +} from "@posthog/api-client/posthog-client"; +import { Button } from "@posthog/quill"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useSourceConfig } from "@posthog/ui/features/inbox/hooks/useSourceConfig"; +import { toast } from "@posthog/ui/primitives/toast"; +import { + Box, + Flex, + Select, + Switch, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; +import { useCallback, useMemo, useState } from "react"; + +interface SchemaPayload { + name: string; + should_sync: boolean; + sync_type: string; +} + +interface DynamicSourceSetupProps { + /** Capitalized DWH source type string, e.g. `"Jira"`. */ + sourceType: string; + title: string; + /** The warehouse tables to sync for this source (forced on at create time). */ + schemas: SchemaPayload[]; + onComplete: () => void; + onCancel: () => void; +} + +type FieldValues = Record; + +const INPUT_TYPES = new Set([ + "text", + "email", + "search", + "url", + "password", + "time", + "number", + "textarea", +]); + +/** Whether a field is a plain text-like input the generic renderer handles. */ +function isInputField( + field: SourceFieldConfig, +): field is SourceFieldInputConfig { + return INPUT_TYPES.has(field.type); +} + +/** + * A field type the generic renderer cannot handle inline (OAuth grants, SSH + * tunnels, file uploads). Sources requiring these still need a bespoke form. + */ +function isUnsupportedField(field: SourceFieldConfig): boolean { + return ( + field.type === "oauth" || + field.type === "ssh-tunnel" || + field.type === "file-upload" + ); +} + +/** + * Walk the currently active fields and collect the names of required inputs and + * selects that are not yet satisfied, so we can gate the submit button and + * validate before posting. A select with a `defaultValue` is always satisfied, + * because the control renders that value pre-selected. + */ +function missingRequiredFields( + config: SourceConfig, + values: FieldValues, +): string[] { + const missing: string[] = []; + const walk = (fields: SourceFieldConfig[]) => { + for (const field of fields) { + if (field.type === "switch-group") { + if (values[field.name]) walk(field.fields); + } else if (field.type === "select") { + const selected = + (values[field.name] as string) ?? field.defaultValue ?? ""; + if (field.required && selected.trim().length === 0) { + missing.push(field.name); + } + const option = field.options.find((o) => o.value === selected); + if (option?.fields) walk(option.fields); + } else if (isInputField(field) && field.required) { + const value = values[field.name]; + if (typeof value !== "string" || value.trim().length === 0) { + missing.push(field.name); + } + } + } + }; + walk(config.fields); + return missing; +} + +/** + * Build the `createExternalDataSource` payload from the collected field values, + * mirroring how PostHog Cloud nests switch-group and select fields. + */ +function buildPayload( + config: SourceConfig, + values: FieldValues, +): Record { + const collect = (fields: SourceFieldConfig[]): Record => { + const out: Record = {}; + for (const field of fields) { + if (field.type === "switch-group") { + const enabled = !!values[field.name]; + out[field.name] = { enabled, ...collect(field.fields) }; + } else if (field.type === "select") { + const selected = (values[field.name] as string) ?? field.defaultValue; + const option = field.options.find((o) => o.value === selected); + out[field.name] = { + selection: selected, + ...(option?.fields ? collect(option.fields) : {}), + }; + } else if (isInputField(field)) { + const value = values[field.name]; + if (typeof value === "string") out[field.name] = value.trim(); + } + } + return out; + }; + return collect(config.fields); +} + +export function DynamicSourceSetup({ + sourceType, + title, + schemas, + onComplete, + onCancel, +}: DynamicSourceSetupProps) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + const client = useAuthenticatedClient(); + const { data: config, isLoading, error } = useSourceConfig(sourceType); + const [values, setValues] = useState({}); + const [submitting, setSubmitting] = useState(false); + + const setValue = useCallback((name: string, value: string | boolean) => { + setValues((prev) => ({ ...prev, [name]: value })); + }, []); + + const hasUnsupportedField = useMemo( + () => (config ? config.fields.some(isUnsupportedField) : false), + [config], + ); + + const canSubmit = useMemo(() => { + if (!config || hasUnsupportedField) return false; + return missingRequiredFields(config, values).length === 0; + }, [config, values, hasUnsupportedField]); + + const handleSubmit = useCallback(async () => { + if (!projectId || !client || !config) return; + setSubmitting(true); + try { + await client.createExternalDataSource(projectId, { + source_type: sourceType, + payload: { ...buildPayload(config, values), schemas }, + }); + toast.success(`${title} data source created`); + onComplete(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to create data source", + ); + } finally { + setSubmitting(false); + } + }, [ + projectId, + client, + config, + values, + schemas, + sourceType, + title, + onComplete, + ]); + + return ( + + {isLoading ? ( + Loading connection form… + ) : error || !config ? ( + + Couldn't load the {title} connection form. Please try again. + + ) : ( + + {config.caption && ( + {config.caption} + )} + {config.fields.map((field) => ( + + ))} + {hasUnsupportedField && ( + + This source needs a connection step that isn't supported here yet. + + )} + + + + + + )} + + ); +} + +function SourceField({ + field, + values, + setValue, +}: { + field: SourceFieldConfig; + values: FieldValues; + setValue: (name: string, value: string | boolean) => void; +}) { + if (field.type === "switch-group") { + const enabled = !!values[field.name]; + return ( + + + setValue(field.name, checked)} + /> + {field.label} + + {field.caption && ( + {field.caption} + )} + {enabled && + field.fields.map((nested) => ( + + ))} + + ); + } + + if (field.type === "select") { + const selected = (values[field.name] as string) ?? field.defaultValue ?? ""; + const option = field.options.find((o) => o.value === selected); + return ( + + {field.label} + setValue(field.name, value)} + > + + + {field.options.map((o) => ( + + {o.label} + + ))} + + + {option?.fields?.map((nested) => ( + + ))} + + ); + } + + if (isInputField(field)) { + const isSecret = field.type === "password" || field.secret === true; + return ( + + {field.label} + {field.type === "textarea" ? ( +