chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user