Files
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

381 lines
14 KiB
TypeScript

'use client'
import { type ComponentType, useCallback, useMemo, useRef } from 'react'
import {
ArrowRight,
ChevronDown,
ChipInput,
chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Search,
} from '@sim/emcn'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import {
blockTypeToIconMap,
formatIntegrationType,
INTEGRATIONS,
type Integration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
ALL_CATEGORY,
CONNECTED_LABEL,
FEATURED_LABEL,
integrationsParsers,
integrationsUrlKeys,
} from '@/app/workspace/[workspaceId]/integrations/search-params'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
/** Slugs surfaced in the pinned Featured section, in display order. */
const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const
const LINK_ROW_CLASSES =
'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
const LINK_ROW_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]'
const LINK_ROW_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]'
const LINK_ROW_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]'
const FEATURED_INTEGRATIONS: readonly Integration[] = (() => {
const bySlug = new Map(INTEGRATIONS.map((i) => [i.slug, i]))
return FEATURED_SLUGS.map((slug) => bySlug.get(slug)).filter(
(i): i is Integration => i !== undefined
)
})()
/** Lookup integration metadata by OAuth service display name (case-insensitive). */
const INTEGRATION_BY_LOWER_NAME: ReadonlyMap<string, Integration> = new Map(
INTEGRATIONS.map((i) => [i.name.toLowerCase(), i])
)
const ALL_CATEGORY_SECTIONS: readonly { label: string; integrations: Integration[] }[] = (() => {
const grouped = new Map<string, Integration[]>()
for (const integration of INTEGRATIONS) {
if (!integration.integrationType) continue
const bucket = grouped.get(integration.integrationType)
if (bucket) bucket.push(integration)
else grouped.set(integration.integrationType, [integration])
}
return Array.from(grouped, ([label, items]) => ({
label,
integrations: [...items].sort((a, b) => a.name.localeCompare(b.name)),
})).sort((a, b) => a.label.localeCompare(b.label))
})()
interface IntegrationItemProps {
blockType: string
slug: string
workspaceId: string
name: string
description?: string | null
icon: ComponentType<{ className?: string }>
}
function IntegrationItem({
blockType,
slug,
workspaceId,
name,
description,
icon: Icon,
}: IntegrationItemProps) {
return (
<Link href={`/workspace/${workspaceId}/integrations/${slug}`} className={LINK_ROW_CLASSES}>
<IntegrationTile blockType={blockType} icon={Icon} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className={LINK_ROW_TITLE_CLASSES}>{name}</span>
{description && <span className={LINK_ROW_SUBTITLE_CLASSES}>{description}</span>}
</div>
<ArrowRight className={LINK_ROW_ARROW_CLASSES} />
</Link>
)
}
interface ConnectedDisplayItem {
credential: WorkspaceCredential
name: string
description: string
serviceName: string
integrationType: string | null
blockType: string
slug: string
icon: ComponentType<{ className?: string }>
}
interface ConnectedItemProps {
href: string
blockType: string
name: string
description: string
icon: ComponentType<{ className?: string }>
}
function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) {
return (
<Link href={href} className={LINK_ROW_CLASSES}>
<IntegrationTile blockType={blockType} icon={Icon} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className={LINK_ROW_TITLE_CLASSES}>{name}</span>
<span className={LINK_ROW_SUBTITLE_CLASSES}>{description}</span>
</div>
<ArrowRight className={LINK_ROW_ARROW_CLASSES} />
</Link>
)
}
export function Integrations() {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] =
useQueryStates(integrationsParsers, integrationsUrlKeys)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. Filtering below is cheap in-memory over a static list,
* so it reads the instant value too.
*/
const setSearchTerm = useCallback(
(value: string) => {
const trimmed = value.trim()
const next = trimmed.length > 0 ? trimmed : null
setIntegrationFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setIntegrationFilters]
)
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
const oauthCredentials = useMemo(
() => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
[credentials]
)
const connectedItems = useMemo<ConnectedDisplayItem[]>(() => {
return oauthCredentials.flatMap((credential) => {
if (!credential.providerId) return []
const service = getServiceConfigByProviderId(credential.providerId)
if (!service) return []
const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
return [
{
credential,
name: credential.displayName,
description: credential.description || `${service.name} integration`,
serviceName: service.name,
integrationType: integration?.integrationType ?? null,
blockType: integration?.type ?? '',
slug: integration?.slug ?? '',
icon: service.icon as ComponentType<{ className?: string }>,
},
]
})
}, [oauthCredentials])
const setSelectedCategory = useCallback(
(category: string) => {
setIntegrationFilters({ category })
},
[setIntegrationFilters]
)
const categoryOptions = [
ALL_CATEGORY,
...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []),
FEATURED_LABEL,
...ALL_CATEGORY_SECTIONS.map((section) => section.label),
]
const isAllCategorySelected = selectedCategory === ALL_CATEGORY
const isFeaturedSelected = selectedCategory === FEATURED_LABEL
const isConnectedSelected = selectedCategory === CONNECTED_LABEL
const filteredCategorySections = useMemo(() => {
// Connected-only view: integration sections are suppressed entirely.
if (isConnectedSelected) return []
const normalizedSearch = urlSearchTerm.trim().toLowerCase()
const matchesSearch = (integration: Integration) =>
!normalizedSearch ||
integration.name.toLowerCase().includes(normalizedSearch) ||
integration.description.toLowerCase().includes(normalizedSearch)
if (isFeaturedSelected) {
const items = FEATURED_INTEGRATIONS.filter(matchesSearch)
return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : []
}
const matchesCategory = (integration: Integration) =>
isAllCategorySelected || integration.integrationType === selectedCategory
// Featured is a curated home-row pin: hide it during search so results
// are not duplicated between the Featured section and the category list.
const featured = normalizedSearch ? [] : FEATURED_INTEGRATIONS.filter(matchesCategory)
const featuredSection =
featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : []
if (isAllCategorySelected) {
const rest = ALL_CATEGORY_SECTIONS.map((section) => ({
label: section.label,
integrations: section.integrations.filter(matchesSearch),
})).filter((section) => section.integrations.length > 0)
return [...featuredSection, ...rest]
}
const integrations = INTEGRATIONS.filter(matchesCategory)
.filter(matchesSearch)
.sort((a, b) => a.name.localeCompare(b.name))
return [
...featuredSection,
...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []),
]
}, [
isAllCategorySelected,
isConnectedSelected,
isFeaturedSelected,
urlSearchTerm,
selectedCategory,
])
const visibleConnectedItems = useMemo(() => {
// Featured-only view: Connected is suppressed (mirror behavior of the
// Featured-only branch above, which renders only the Featured section).
if (isFeaturedSelected) return []
const normalizedSearch = urlSearchTerm.trim().toLowerCase()
return connectedItems.filter((item) => {
const matchesCategory =
isAllCategorySelected || isConnectedSelected || item.integrationType === selectedCategory
if (!matchesCategory) return false
if (!normalizedSearch) return true
return (
item.name.toLowerCase().includes(normalizedSearch) ||
item.description.toLowerCase().includes(normalizedSearch) ||
item.serviceName.toLowerCase().includes(normalizedSearch)
)
})
}, [
connectedItems,
isAllCategorySelected,
isConnectedSelected,
isFeaturedSelected,
urlSearchTerm,
selectedCategory,
])
const showNoResults =
Boolean(urlSearchTerm.trim() || !isAllCategorySelected) &&
filteredCategorySections.length === 0 &&
visibleConnectedItems.length === 0
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<IntegrationTabsHeader active='integrations' workspaceId={workspaceId} />
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<ShowcaseWithExplore prompt='Explain the integrations in Sim and what I should connect.' />
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
className='min-w-0 flex-1'
placeholder='Search integrations...'
value={urlSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={credentialsLoading}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type='button' className={chipVariants({ variant: 'filled', flush: true })}>
<span className='text-[var(--text-body)]'>
{selectedCategory === ALL_CATEGORY
? selectedCategory
: formatIntegrationType(selectedCategory)}
</span>
<ChevronDown className='h-[7px] w-[9px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='min-w-[160px]'>
{categoryOptions.map((category) => (
<DropdownMenuItem key={category} onSelect={() => setSelectedCategory(category)}>
{category === ALL_CATEGORY ? category : formatIntegrationType(category)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className='flex flex-col gap-7'>
{visibleConnectedItems.length > 0 && (
<IntegrationSection label={CONNECTED_LABEL}>
{visibleConnectedItems.map((item) => (
<ConnectedItem
key={item.credential.id}
href={`/workspace/${workspaceId}/integrations/connected/${item.credential.id}`}
blockType={item.blockType}
name={item.name}
description={item.description}
icon={item.icon}
/>
))}
</IntegrationSection>
)}
{filteredCategorySections.map((section) => (
<IntegrationSection key={section.label} label={formatIntegrationType(section.label)}>
{section.integrations.map((integration) => {
const Icon = blockTypeToIconMap[integration.type]
if (!Icon) return null
return (
<IntegrationItem
key={integration.type}
blockType={integration.type}
slug={integration.slug}
workspaceId={workspaceId}
name={integration.name}
description={integration.description}
icon={Icon}
/>
)
})}
</IntegrationSection>
))}
{showNoResults && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
{urlSearchTerm.trim()
? `No integrations found matching “${urlSearchTerm}”`
: 'No integrations in this category'}
</div>
)}
</div>
</div>
</div>
</div>
)
}