From e91f9b64219e5f00dff04bfd3f7f5680fac32e83 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 9 Jul 2026 13:36:10 +0100 Subject: [PATCH 1/2] feat(data-warehouse): add Jira as a self-driving inbox source Wire Jira into the Self-driving Inbox source toggles (source_product "jira", issues table) alongside GitHub/Linear/Zendesk/pganalyze. Instead of hardcoding another per-source credential form, add a generic DynamicSourceSetup that renders the connect form from the warehouse wizard endpoint (external_data_sources/wizard/?source_type=Jira), so field names/labels/required/secret come from the backend and never drift. Jira routes through it; GitHub (repo picker) and Linear (OAuth) keep their bespoke flows. Also add the adding-inbox-sources skill documenting the end-to-end (two-repo) pattern for adding further sources. Note: requires the matching posthog/posthog signals-scout support (source_product choice + Jira issues emitter) to land first, or the toggle will 400 on enable. --- packages/api-client/src/posthog-client.ts | 105 ++++++ packages/core/src/inbox/dataSourceService.ts | 30 +- .../core/src/inbox/signalSourceService.ts | 8 +- packages/shared/src/analytics-events.ts | 1 + packages/shared/src/inbox-types.ts | 1 + .../inbox/components/DataSourceSetup.tsx | 14 +- .../inbox/components/DynamicSourceSetup.tsx | 337 ++++++++++++++++++ .../inbox/components/SignalSourceToggles.tsx | 19 + .../inbox/components/utils/JiraIcon.tsx | 24 ++ .../components/utils/source-product-icons.tsx | 6 + .../ui/src/features/inbox/filterOptions.tsx | 2 + .../inbox/hooks/useSignalSourceToggles.ts | 14 +- .../features/inbox/hooks/useSourceConfig.ts | 24 ++ 13 files changed, 580 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx create mode 100644 packages/ui/src/features/inbox/components/utils/JiraIcon.tsx create mode 100644 packages/ui/src/features/inbox/hooks/useSourceConfig.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index c22eee052f..ed9256dfcb 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..67e3434029 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"], }; @@ -33,6 +39,12 @@ export interface GithubDataSourceParams { githubIntegrationId: number; } +export interface JiraDataSourceParams { + subdomain: string; + email: string; + apiToken: string; +} + export interface ZendeskDataSourceParams { subdomain: string; apiKey: string; @@ -83,6 +95,22 @@ export class DataSourceService { }); } + async createJiraDataSource( + client: PostHogAPIClient, + projectId: number, + params: JiraDataSourceParams, + ): Promise { + await client.createExternalDataSource(projectId, { + source_type: "Jira", + payload: { + subdomain: params.subdomain, + email: params.email, + api_token: params.apiToken, + schemas: schemasPayload("jira"), + }, + }); + } + async createZendeskDataSource( client: PostHogAPIClient, projectId: number, 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 b3914781d7..122ee0d804 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -785,6 +785,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..cb5787b0f2 --- /dev/null +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -0,0 +1,337 @@ +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, 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" + ); +} + +/** + * Collect the required text-input field names that are currently active, so we + * can gate the submit button and validate before posting. + */ +function requiredInputNames( + config: SourceConfig, + values: FieldValues, +): string[] { + const names: 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]; + const option = field.options.find((o) => o.value === selected); + if (option?.fields) walk(option.fields); + } else if (isInputField(field) && field.required) { + names.push(field.name); + } + } + }; + walk(config.fields); + return names; +} + +/** + * 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 requiredInputNames(config, values).every((name) => { + const value = values[name]; + return typeof value === "string" && value.trim().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; + const inputType = field.type === "textarea" ? "text" : field.type; + return ( + + setValue(field.name, e.target.value)} + /> + {field.caption && ( + {field.caption} + )} + + ); + } + + return null; +} + +function SetupFormContainer({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + + {title} + + {children} + + + ); +} diff --git a/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx b/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx index f09935f5bf..ab5854df6b 100644 --- a/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx +++ b/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx @@ -11,6 +11,7 @@ import { } from "@phosphor-icons/react"; import type { SignalSourceConfig } from "@posthog/api-client/posthog-client"; import { Button } from "@posthog/quill"; +import { JiraIcon } from "@posthog/ui/features/inbox/components/utils/JiraIcon"; import { PgAnalyzeIcon } from "@posthog/ui/features/inbox/components/utils/PgAnalyzeIcon"; import { Badge } from "@posthog/ui/primitives/Badge"; import { Box, Flex, Spinner, Switch, Text, Tooltip } from "@radix-ui/themes"; @@ -21,6 +22,7 @@ export interface SignalSourceValues { error_tracking: boolean; github: boolean; linear: boolean; + jira: boolean; zendesk: boolean; conversations: boolean; pganalyze: boolean; @@ -290,6 +292,10 @@ export function SignalSourceToggles({ (checked: boolean) => onToggle("linear", checked), [onToggle], ); + const toggleJira = useCallback( + (checked: boolean) => onToggle("jira", checked), + [onToggle], + ); const toggleZendesk = useCallback( (checked: boolean) => onToggle("zendesk", checked), [onToggle], @@ -304,6 +310,7 @@ export function SignalSourceToggles({ ); const setupGithub = useCallback(() => onSetup?.("github"), [onSetup]); const setupLinear = useCallback(() => onSetup?.("linear"), [onSetup]); + const setupJira = useCallback(() => onSetup?.("jira"), [onSetup]); const setupZendesk = useCallback(() => onSetup?.("zendesk"), [onSetup]); const setupPgAnalyze = useCallback(() => onSetup?.("pganalyze"), [onSetup]); @@ -391,6 +398,18 @@ export function SignalSourceToggles({ loading={sourceStates?.linear?.loading} syncStatus={sourceStates?.linear?.syncStatus} /> + } + label="Jira" + description="Monitor new issues and updates" + checked={value.jira} + onCheckedChange={toggleJira} + disabled={disabled} + requiresSetup={sourceStates?.jira?.requiresSetup} + onSetup={setupJira} + loading={sourceStates?.jira?.loading} + syncStatus={sourceStates?.jira?.syncStatus} + /> } label="Zendesk" diff --git a/packages/ui/src/features/inbox/components/utils/JiraIcon.tsx b/packages/ui/src/features/inbox/components/utils/JiraIcon.tsx new file mode 100644 index 0000000000..2ded6bff98 --- /dev/null +++ b/packages/ui/src/features/inbox/components/utils/JiraIcon.tsx @@ -0,0 +1,24 @@ +import type { IconProps } from "@phosphor-icons/react"; + +// Inlined single-path Jira mark so the SVG inherits `currentColor` from the +// parent text color and adapts to light/dark mode the same way as the Phosphor +// icons used elsewhere. +// +// Accepts the Phosphor `IconProps` shape so it can be substituted for one in +// the SOURCE_PRODUCT_META table without a type cast. Only `size` and +// `className` are honored – `weight`, `mirrored`, etc. are ignored. +export function JiraIcon({ size = 20, className }: IconProps) { + return ( + + ); +} diff --git a/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx b/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx index 248ef1d926..eb0d625e18 100644 --- a/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx +++ b/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx @@ -9,6 +9,7 @@ import { TicketIcon, VideoIcon, } from "@phosphor-icons/react"; +import { JiraIcon } from "@posthog/ui/features/inbox/components/utils/JiraIcon"; import { PgAnalyzeIcon } from "@posthog/ui/features/inbox/components/utils/PgAnalyzeIcon"; import type { SourceProduct } from "@posthog/ui/features/inbox/stores/inboxSignalsFilterStore"; import type { ComponentType } from "react"; @@ -75,6 +76,11 @@ export const SOURCE_PRODUCT_META: Partial< color: "var(--blue-9)", label: "Linear", }, + jira: { + Icon: JiraIcon, + color: "var(--blue-11)", + label: "Jira", + }, zendesk: { Icon: TicketIcon, color: "var(--green-9)", diff --git a/packages/ui/src/features/inbox/filterOptions.tsx b/packages/ui/src/features/inbox/filterOptions.tsx index 691169be53..588a178f15 100644 --- a/packages/ui/src/features/inbox/filterOptions.tsx +++ b/packages/ui/src/features/inbox/filterOptions.tsx @@ -18,6 +18,7 @@ import type { SignalReportPriority, SourceProduct, } from "@posthog/shared/types"; +import { JiraIcon } from "@posthog/ui/features/inbox/components/utils/JiraIcon"; import { PgAnalyzeIcon } from "@posthog/ui/features/inbox/components/utils/PgAnalyzeIcon"; import type { ReactNode } from "react"; @@ -93,6 +94,7 @@ export const INBOX_SOURCE_OPTIONS: { }, { value: "github", label: "GitHub", icon: }, { value: "linear", label: "Linear", icon: }, + { value: "jira", label: "Jira", icon: }, { value: "zendesk", label: "Zendesk", icon: }, { value: "conversations", diff --git a/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts b/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts index c882f187ce..faf0e646b3 100644 --- a/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts @@ -12,7 +12,12 @@ import { useCallback, useMemo, useRef, useState } from "react"; type SourceProduct = SignalSourceConfig["source_product"]; type SourceType = SignalSourceConfig["source_type"]; -type SetupSourceProduct = "github" | "linear" | "zendesk" | "pganalyze"; +type SetupSourceProduct = + | "github" + | "linear" + | "jira" + | "zendesk" + | "pganalyze"; const SOURCE_TYPE_MAP: Record< Exclude, @@ -21,6 +26,7 @@ const SOURCE_TYPE_MAP: Record< session_replay: "session_analysis_cluster", github: "issue", linear: "issue", + jira: "issue", zendesk: "ticket", conversations: "ticket", pganalyze: "issue", @@ -37,6 +43,7 @@ const SOURCE_LABELS: Record = { error_tracking: "Error tracking", github: "GitHub Issues", linear: "Linear Issues", + jira: "Jira Issues", zendesk: "Zendesk Tickets", conversations: "PostHog Support", pganalyze: "pganalyze", @@ -48,6 +55,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" }, }; @@ -57,6 +65,7 @@ const ALL_SOURCE_PRODUCTS: (keyof SignalSourceValues)[] = [ "error_tracking", "github", "linear", + "jira", "zendesk", "conversations", "pganalyze", @@ -76,6 +85,7 @@ function computeValues( error_tracking: false, github: false, linear: false, + jira: false, zendesk: false, conversations: false, pganalyze: false, @@ -203,7 +213,7 @@ export function useSignalSourceToggles() { if (!requiredSchema) return; const issuesFullReplication = - (product === "github" || product === "linear") && + (product === "github" || product === "linear" || product === "jira") && dwConfig.requiredTable === "issues"; if (issuesFullReplication) { diff --git a/packages/ui/src/features/inbox/hooks/useSourceConfig.ts b/packages/ui/src/features/inbox/hooks/useSourceConfig.ts new file mode 100644 index 0000000000..e17a452af6 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useSourceConfig.ts @@ -0,0 +1,24 @@ +import type { SourceConfig } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; + +/** + * Fetch the connect-form field schema for a single external data source type + * (e.g. `"Jira"`) from the warehouse wizard endpoint, so setup forms can be + * rendered from the backend's field definitions instead of being hardcoded. + */ +export function useSourceConfig(sourceType: string | null) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + ["external-data-source-config", projectId, sourceType], + async (client) => { + if (!projectId || !sourceType) return null; + const configs = await client.getExternalDataSourceConfigs( + projectId, + sourceType, + ); + return configs[sourceType] ?? null; + }, + { enabled: !!projectId && !!sourceType, staleTime: 300_000 }, + ); +} From ebf5152b4ee79e54605898db6cfe748360719d51 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 9 Jul 2026 17:21:07 +0100 Subject: [PATCH 2/2] fix(inbox): address review on generic source setup form - Render `textarea` fields with a real multi-line `TextArea` instead of a single-line `TextField.Root` typed as text. - Gate the submit button on required `select` fields with no value (and no defaultValue), not just required text inputs. - Show a visible label above text-like inputs, matching the select branch. - Remove the unreachable `createJiraDataSource` / `JiraDataSourceParams` dead code now that Jira setup goes through the generic form. Generated-By: PostHog Code Task-Id: be97b984-e201-438d-94c8-b6fdbe0fc702 --- packages/core/src/inbox/dataSourceService.ts | 22 ------- .../inbox/components/DynamicSourceSetup.tsx | 61 +++++++++++++------ 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/packages/core/src/inbox/dataSourceService.ts b/packages/core/src/inbox/dataSourceService.ts index 67e3434029..4b82407abc 100644 --- a/packages/core/src/inbox/dataSourceService.ts +++ b/packages/core/src/inbox/dataSourceService.ts @@ -39,12 +39,6 @@ export interface GithubDataSourceParams { githubIntegrationId: number; } -export interface JiraDataSourceParams { - subdomain: string; - email: string; - apiToken: string; -} - export interface ZendeskDataSourceParams { subdomain: string; apiKey: string; @@ -95,22 +89,6 @@ export class DataSourceService { }); } - async createJiraDataSource( - client: PostHogAPIClient, - projectId: number, - params: JiraDataSourceParams, - ): Promise { - await client.createExternalDataSource(projectId, { - source_type: "Jira", - payload: { - subdomain: params.subdomain, - email: params.email, - api_token: params.apiToken, - schemas: schemasPayload("jira"), - }, - }); - } - async createZendeskDataSource( client: PostHogAPIClient, projectId: number, diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx index cb5787b0f2..9cc24b2482 100644 --- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -8,7 +8,15 @@ 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, TextField } from "@radix-ui/themes"; +import { + Box, + Flex, + Select, + Switch, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; import { useCallback, useMemo, useState } from "react"; interface SchemaPayload { @@ -60,29 +68,38 @@ function isUnsupportedField(field: SourceFieldConfig): boolean { } /** - * Collect the required text-input field names that are currently active, so we - * can gate the submit button and validate before posting. + * 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 requiredInputNames( +function missingRequiredFields( config: SourceConfig, values: FieldValues, ): string[] { - const names: 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]; + 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) { - names.push(field.name); + const value = values[field.name]; + if (typeof value !== "string" || value.trim().length === 0) { + missing.push(field.name); + } } } }; walk(config.fields); - return names; + return missing; } /** @@ -140,10 +157,7 @@ export function DynamicSourceSetup({ const canSubmit = useMemo(() => { if (!config || hasUnsupportedField) return false; - return requiredInputNames(config, values).every((name) => { - const value = values[name]; - return typeof value === "string" && value.trim().length > 0; - }); + return missingRequiredFields(config, values).length === 0; }, [config, values, hasUnsupportedField]); const handleSubmit = useCallback(async () => { @@ -295,15 +309,24 @@ function SourceField({ if (isInputField(field)) { const isSecret = field.type === "password" || field.secret === true; - const inputType = field.type === "textarea" ? "text" : field.type; return ( - setValue(field.name, e.target.value)} - /> + {field.label} + {field.type === "textarea" ? ( +