import { useCallback, useState } from "react"; import { parse } from "tldts"; import { CardStack, CardStackContent, CardStackEntryField } from "../components/card-stack"; import { Input } from "../components/input"; import { normalizeNamespaceInput, slugifyNamespace } from "./namespace"; export { normalizeNamespaceInput, slugifyNamespace } from "./namespace"; /** * Derives a display-name candidate from a URL by extracting its apex domain * label (e.g. `https://api.shopify.com/graphql` → `"Shopify"`) and * title-casing it. Returns `null` if the URL has no parseable domain. */ export function displayNameFromUrl(url: string): string | null { const trimmed = url.trim(); if (!trimmed) return null; const parsed = parse(trimmed); const label = parsed.domainWithoutSuffix; if (!label) return null; return label.charAt(0).toUpperCase() + label.slice(1); } export function domainLabelFromUrl(url: string): string | null { const trimmed = url.trim(); if (!trimmed) return null; return parse(trimmed).domainWithoutSuffix ?? null; } export function pascalCaseDomainLabel(label: string): string | null { const words = label .split(/[^a-z0-9]+/i) .map((word) => word.trim()) .filter(Boolean); if (words.length === 0) return null; return words .map((word) => { const normalized = word.toLowerCase(); return normalized.charAt(0).toUpperCase() + normalized.slice(1); }) .join(""); } export function integrationDisplayNameFromUrl(url: string, sourceKind: string): string | null { const label = domainLabelFromUrl(url); const displayLabel = label ? pascalCaseDomainLabel(label) : null; return displayLabel ? `${displayLabel} ${sourceKind}` : null; } // --------------------------------------------------------------------------- // Hook — owns the name + namespace state with namespace auto-derivation // --------------------------------------------------------------------------- export interface IntegrationIdentity { /** Display name — the user's override if they've typed one, otherwise the fallback. */ readonly name: string; /** Namespace — the user's override if they've typed one, otherwise slugified from `name`. */ readonly namespace: string; readonly setName: (name: string) => void; readonly setNamespace: (namespace: string) => void; /** Clears any user overrides so both fields return to deriving from the fallback. */ readonly reset: () => void; } export interface UseIntegrationIdentityOptions { /** * Fallback display name — used when the user hasn't typed one. Pass a * value computed from the caller's reactive state (probe result, URL * apex domain, template default, etc.) and it'll flow through to `name` * automatically. */ readonly fallbackName?: string; /** Fallback namespace — defaults to `slugifyNamespace(fallbackName ?? "")`. */ readonly fallbackNamespace?: string; } /** * Manages a display name and a derived namespace. Both fields are pure * derived state: the user's `setName` / `setNamespace` call stores an * override, otherwise the hook returns the caller-supplied fallback * (passed fresh on every render). Call `reset()` to drop overrides. */ export function useIntegrationIdentity( options?: UseIntegrationIdentityOptions, ): IntegrationIdentity { const [nameOverride, setNameOverride] = useState(null); const [namespaceOverride, setNamespaceOverride] = useState(null); const fallbackName = options?.fallbackName ?? ""; const name = nameOverride ?? fallbackName; const fallbackNamespace = options?.fallbackNamespace ?? slugifyNamespace(name); const namespace = namespaceOverride ?? fallbackNamespace; const setName = useCallback((next: string) => { setNameOverride(next); }, []); const setNamespace = useCallback((next: string) => { setNamespaceOverride(normalizeNamespaceInput(next)); }, []); const reset = useCallback(() => { setNameOverride(null); setNamespaceOverride(null); }, []); return { name, namespace, setName, setNamespace, reset }; } // --------------------------------------------------------------------------- // UI — two fields, wrapped in a shared CardStack // --------------------------------------------------------------------------- export interface IntegrationIdentityFieldsProps { readonly identity: IntegrationIdentity; readonly namePlaceholder?: string; readonly namespacePlaceholder?: string; readonly nameLabel?: string; readonly namespaceHint?: string; /** * When true, the namespace field is rendered disabled — useful on Edit * forms, where the namespace is the source's identity and changing it * would require a delete + recreate flow. */ readonly namespaceReadOnly?: boolean; } export function IntegrationIdentityFields({ identity, namePlaceholder = "e.g. Sentry API", namespacePlaceholder = "sentry_api", nameLabel = "Display Name", namespaceHint, namespaceReadOnly = false, }: IntegrationIdentityFieldsProps) { const effectiveNamespaceHint = namespaceHint ?? (namespaceReadOnly ? "The namespace is part of the source's identity and cannot be changed." : undefined); return ( ); } export function IntegrationIdentityFieldRows({ identity, namePlaceholder = "e.g. Sentry API", namespacePlaceholder = "sentry_api", nameLabel = "Display Name", namespaceHint, namespaceReadOnly = false, }: IntegrationIdentityFieldsProps) { const effectiveNamespaceHint = namespaceHint ?? (namespaceReadOnly ? "The namespace is part of the source's identity and cannot be changed." : undefined); return (
identity.setName((e.target as HTMLInputElement).value)} placeholder={namePlaceholder} className="text-sm" /> identity.setNamespace((e.target as HTMLInputElement).value)} placeholder={namespacePlaceholder} className="font-mono text-sm" disabled={namespaceReadOnly} />
); }