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:
+33
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface IntegrationBlockDetailFallbackProps {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspense fallback for the integration detail page — the back-link chrome
|
||||
* shown while {@link IntegrationBlockDetail} hydrates.
|
||||
*
|
||||
* This MUST be a client component. The lucide `ArrowLeft` passed as `ChipLink`'s
|
||||
* `leftIcon` is a function, and functions cannot cross the server→client
|
||||
* boundary as props. Rendering the fallback from the server `page.tsx` directly
|
||||
* threw a React Server Components error ("Functions cannot be passed directly to
|
||||
* Client Components") that surfaced as the integrations error boundary. Keeping
|
||||
* the icon inside a client component avoids the boundary crossing entirely.
|
||||
*/
|
||||
export function IntegrationBlockDetailFallback({
|
||||
workspaceId,
|
||||
}: IntegrationBlockDetailFallbackProps) {
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
|
||||
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
|
||||
Integrations
|
||||
</ChipLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
|
||||
import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
blockTypeToIconMap,
|
||||
type Integration,
|
||||
resolveOAuthServiceForIntegration,
|
||||
} from '@/lib/integrations'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
|
||||
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
|
||||
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
|
||||
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
|
||||
import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
|
||||
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
|
||||
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
|
||||
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
|
||||
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
|
||||
import {
|
||||
getSuggestedSkillsForBlock,
|
||||
getTemplatesForBlock,
|
||||
type ScopedBlockTemplate,
|
||||
} from '@/blocks/registry'
|
||||
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
|
||||
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
|
||||
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
|
||||
|
||||
/** Maximum number of overlapping icon tiles rendered per template row. */
|
||||
const TEMPLATE_CLUSTER_MAX = 3 as const
|
||||
|
||||
/**
|
||||
* Z-index per cluster position so the primary tile reads on top and trailing
|
||||
* tiles cascade behind it.
|
||||
*/
|
||||
const TEMPLATE_TILE_Z = ['z-30', 'z-20', 'z-10'] as const
|
||||
|
||||
interface IntegrationBlockDetailProps {
|
||||
integration: Integration
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
useOAuthReturnRouter()
|
||||
const router = useRouter()
|
||||
const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser)
|
||||
const Icon = blockTypeToIconMap[integration.type]
|
||||
const matchingTemplates = getTemplatesForBlock(integration.type)
|
||||
const suggestedSkills = getSuggestedSkillsForBlock(integration.type)
|
||||
const oauthService = resolveOAuthServiceForIntegration(integration)
|
||||
const [oauthOpen, setOAuthOpen] = useState(false)
|
||||
|
||||
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
|
||||
workspaceId,
|
||||
enabled: Boolean(workspaceId),
|
||||
})
|
||||
|
||||
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
|
||||
|
||||
const connectedCredentials = useMemo(() => {
|
||||
if (!oauthService) return []
|
||||
return credentials.filter(
|
||||
(c) =>
|
||||
(c.type === 'oauth' || c.type === 'service_account') &&
|
||||
c.providerId &&
|
||||
getServiceConfigByProviderId(c.providerId)?.providerId === oauthService.providerId
|
||||
)
|
||||
}, [credentials, oauthService])
|
||||
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
|
||||
const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
|
||||
const blockOverlayVersion = useCustomBlockOverlayVersion()
|
||||
// Custom Slack bots ride the slack_v2 preview flag: the setup surface stays
|
||||
// hidden until that block is revealed for this viewer.
|
||||
const slackBotPreviewHidden = useMemo(() => {
|
||||
if (!isSlackBot) return false
|
||||
const v2 = getBlock('slack_v2')
|
||||
return !v2 || isHiddenUnder(overlayVisibility(), v2)
|
||||
}, [isSlackBot, blockOverlayVersion])
|
||||
const hasServiceAccount =
|
||||
Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
|
||||
const hasHandledConnectQueryRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHandledConnectQueryRef.current) return
|
||||
if (!connectMode) return
|
||||
|
||||
let handled = false
|
||||
if (connectMode === CONNECT_MODE.oauth && oauthService) {
|
||||
setOAuthOpen(true)
|
||||
handled = true
|
||||
} else if (connectMode === CONNECT_MODE.serviceAccount && hasServiceAccount) {
|
||||
setServiceAccountOpen(true)
|
||||
handled = true
|
||||
}
|
||||
if (!handled) return
|
||||
|
||||
hasHandledConnectQueryRef.current = true
|
||||
void setConnectMode(null, { history: 'replace', scroll: false })
|
||||
}, [connectMode, oauthService, hasServiceAccount, setConnectMode])
|
||||
|
||||
const connectOptions = oauthService
|
||||
? [
|
||||
{
|
||||
value: CONNECT_MODE.oauth,
|
||||
label: 'Connect with OAuth',
|
||||
icon: oauthService.serviceIcon,
|
||||
},
|
||||
{
|
||||
value: CONNECT_MODE.serviceAccount,
|
||||
label: isSlackBot ? 'Set up a custom bot' : 'Add service account',
|
||||
icon: oauthService.serviceIcon,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const handleSelectConnectOption = (value: string) => {
|
||||
if (value === CONNECT_MODE.oauth) setOAuthOpen(true)
|
||||
else if (value === CONNECT_MODE.serviceAccount) setServiceAccountOpen(true)
|
||||
}
|
||||
|
||||
const handleAddInChat = () => {
|
||||
storeCuratedPrompt(`Explore ${integration.name}. What can I do?`)
|
||||
router.push(`/workspace/${workspaceId}/home`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
|
||||
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
|
||||
Integrations
|
||||
</ChipLink>
|
||||
<div className='ml-auto flex items-center'>
|
||||
{oauthService ? (
|
||||
hasServiceAccount ? (
|
||||
<ChipDropdown
|
||||
variant='primary'
|
||||
leftIcon={Plus}
|
||||
placeholder='Add to Sim'
|
||||
showSelectedCheck={false}
|
||||
options={connectOptions}
|
||||
onChange={handleSelectConnectOption}
|
||||
matchTriggerWidth={false}
|
||||
/>
|
||||
) : (
|
||||
<Chip variant='primary' leftIcon={Plus} onClick={() => setOAuthOpen(true)}>
|
||||
Add to Sim
|
||||
</Chip>
|
||||
)
|
||||
) : (
|
||||
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
|
||||
Add to Sim
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{oauthService && (
|
||||
<ConnectOAuthModal
|
||||
mode='connect'
|
||||
origin='integrations'
|
||||
open={oauthOpen}
|
||||
onOpenChange={setOAuthOpen}
|
||||
workspaceId={workspaceId}
|
||||
providerId={oauthService.providerId}
|
||||
requiredScopes={oauthService.requiredScopes}
|
||||
serviceName={oauthService.serviceName}
|
||||
serviceIcon={oauthService.serviceIcon}
|
||||
/>
|
||||
)}
|
||||
{hasServiceAccount && oauthService?.serviceAccountProviderId && (
|
||||
<ConnectServiceAccountModal
|
||||
open={serviceAccountOpen}
|
||||
onOpenChange={setServiceAccountOpen}
|
||||
workspaceId={workspaceId}
|
||||
serviceAccountProviderId={oauthService.serviceAccountProviderId}
|
||||
serviceName={oauthService.serviceName}
|
||||
serviceIcon={oauthService.serviceIcon}
|
||||
/>
|
||||
)}
|
||||
<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'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{Icon ? (
|
||||
<IntegrationTile blockType={integration.type} icon={Icon} />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)]',
|
||||
getTileIconColorClass(integration.bgColor)
|
||||
)}
|
||||
style={{ background: integration.bgColor }}
|
||||
>
|
||||
{integration.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h1 className='font-medium text-[var(--text-body)] text-lg'>{integration.name}</h1>
|
||||
<p className='text-[var(--text-muted)] text-md'>{integration.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectedCredentials.length > 0 && (
|
||||
<IntegrationSection label='Connected'>
|
||||
{connectedCredentials.map((credential) => (
|
||||
<Link
|
||||
key={credential.id}
|
||||
href={`/workspace/${workspaceId}/integrations/connected/${credential.id}`}
|
||||
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
{Icon && <IntegrationTile blockType={integration.type} icon={Icon} />}
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>
|
||||
{credential.displayName}
|
||||
</span>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>
|
||||
{credential.description || oauthService?.serviceName}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</Link>
|
||||
))}
|
||||
</IntegrationSection>
|
||||
)}
|
||||
|
||||
{suggestedSkills.length > 0 && (
|
||||
<IntegrationSkillsSection
|
||||
skills={suggestedSkills}
|
||||
workspaceId={workspaceId}
|
||||
integrationType={integration.type}
|
||||
/>
|
||||
)}
|
||||
|
||||
{matchingTemplates.length > 0 && (
|
||||
<TemplatesSection
|
||||
integration={integration}
|
||||
templates={matchingTemplates}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TemplatesSectionProps {
|
||||
integration: Integration
|
||||
templates: readonly ScopedBlockTemplate[]
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
function TemplatesSection({ integration, templates, workspaceId }: TemplatesSectionProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleSelect = (prompt: string) => {
|
||||
storeCuratedPrompt(prompt)
|
||||
router.push(`/workspace/${workspaceId}/home`)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='flex flex-col'>
|
||||
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Templates</span>
|
||||
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
{templates.map((template) => {
|
||||
const blockTypes = [integration.type, ...template.otherBlockTypes].slice(
|
||||
0,
|
||||
TEMPLATE_CLUSTER_MAX
|
||||
)
|
||||
return (
|
||||
<TemplateRow
|
||||
key={template.title}
|
||||
blockTypes={blockTypes}
|
||||
title={template.title}
|
||||
prompt={template.prompt}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface TemplateRowProps {
|
||||
blockTypes: string[]
|
||||
title: string
|
||||
prompt: string
|
||||
onSelect: (prompt: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Template row that mirrors `IntegrationItem` from the integrations index
|
||||
* byte-for-byte (icon cluster · title · description · trailing `ArrowRight`).
|
||||
* Renders as a `<button>` because click seeds the home page chat with `prompt`
|
||||
* and navigates to the workspace home, matching the `ShowcaseWithExplore` flow.
|
||||
*/
|
||||
function TemplateRow({ blockTypes, title, prompt, onSelect }: TemplateRowProps) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onSelect(prompt)}
|
||||
className='group flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<TemplateIcons blockTypes={blockTypes} />
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>{prompt}</span>
|
||||
</div>
|
||||
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface TemplateIconsProps {
|
||||
blockTypes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal overlapping icon cluster. Primary integration (idx === 0) sits
|
||||
* left and on top, rendered identically to a bare `IntegrationTile` so it
|
||||
* matches `IntegrationItem` outside the templates list with no halo. Trailing
|
||||
* tiles cascade behind with negative margin and carry a non-layout-affecting
|
||||
* outline whose color tracks the row background exactly — `--bg` at rest and
|
||||
* `--surface-active` on row hover via the parent `group`. The outline is
|
||||
* visually invisible in both states yet cleanly cuts each silhouette from the
|
||||
* tile behind it. `outline` is preferred over `ring` here because it has no
|
||||
* `box-shadow` specificity collisions and lets us transition just the
|
||||
* `outline-color` token rather than the full shadow stack.
|
||||
*/
|
||||
function TemplateIcons({ blockTypes }: TemplateIconsProps) {
|
||||
return (
|
||||
<span aria-hidden className='flex items-center'>
|
||||
{blockTypes.map((bt, idx) => {
|
||||
const ToolIcon = blockTypeToIconMap[bt]
|
||||
if (!ToolIcon) return null
|
||||
const z = TEMPLATE_TILE_Z[idx]
|
||||
if (!z) return null
|
||||
const isTrailing = idx > 0
|
||||
return (
|
||||
<span
|
||||
key={bt}
|
||||
className={cn(
|
||||
'[&:not(:first-child)]:-ml-2 relative rounded-xl first:ml-0',
|
||||
z,
|
||||
isTrailing &&
|
||||
'outline outline-2 outline-[var(--bg)] transition-[outline-color] duration-150 group-hover:outline-[var(--surface-active)]'
|
||||
)}
|
||||
>
|
||||
<IntegrationTile blockType={bt} icon={ToolIcon} />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Chip, toast } from '@sim/emcn'
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import type { SuggestedSkill } from '@/blocks/types'
|
||||
import { useCreateSkill, useSkills } from '@/hooks/queries/skills'
|
||||
|
||||
interface IntegrationSkillsSectionProps {
|
||||
skills: readonly SuggestedSkill[]
|
||||
workspaceId: string
|
||||
integrationType: string
|
||||
}
|
||||
|
||||
interface SkillRowProps {
|
||||
skill: SuggestedSkill
|
||||
added: boolean
|
||||
pending: boolean
|
||||
disabled: boolean
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
function SkillRow({ skill, added, pending, disabled, onAdd }: SkillRowProps) {
|
||||
return (
|
||||
<div className='flex items-center gap-2.5 rounded-lg p-2'>
|
||||
<SkillTile />
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>{skill.name}</span>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>{skill.description}</span>
|
||||
</div>
|
||||
{added ? (
|
||||
<Chip leftIcon={Check} disabled flush>
|
||||
Added
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip variant='primary' leftIcon={Plus} onClick={onAdd} disabled={disabled} flush>
|
||||
{pending ? 'Adding...' : 'Add'}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated, research-backed skills for an integration. Each row adds the skill
|
||||
* to the workspace via the same `useCreateSkill` mutation the Skills page uses;
|
||||
* `useSkills` is the single source of truth for the "Added" state, so a skill
|
||||
* removed elsewhere correctly reverts to "Add".
|
||||
*/
|
||||
export function IntegrationSkillsSection({
|
||||
skills,
|
||||
workspaceId,
|
||||
integrationType,
|
||||
}: IntegrationSkillsSectionProps) {
|
||||
const posthog = usePostHog()
|
||||
const { data: existingSkills = [], isPending, isPlaceholderData } = useSkills(workspaceId)
|
||||
const createSkill = useCreateSkill()
|
||||
const skillsReady = !isPending && !isPlaceholderData
|
||||
const [pendingNames, setPendingNames] = useState<ReadonlySet<string>>(new Set())
|
||||
const inFlightRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const existingNames = useMemo(() => new Set(existingSkills.map((s) => s.name)), [existingSkills])
|
||||
|
||||
const handleAdd = async (skill: SuggestedSkill, position: number) => {
|
||||
if (inFlightRef.current.has(skill.name)) return
|
||||
inFlightRef.current.add(skill.name)
|
||||
setPendingNames((prev) => new Set(prev).add(skill.name))
|
||||
try {
|
||||
await createSkill.mutateAsync({ workspaceId, skill })
|
||||
captureEvent(posthog, 'integration_skill_added', {
|
||||
workspace_id: workspaceId,
|
||||
integration_type: integrationType,
|
||||
skill_name: skill.name,
|
||||
position,
|
||||
skill_count: skills.length,
|
||||
})
|
||||
} catch {
|
||||
toast.error(`Failed to add "${skill.name}" — please try again`)
|
||||
} finally {
|
||||
inFlightRef.current.delete(skill.name)
|
||||
setPendingNames((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(skill.name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='flex flex-col'>
|
||||
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Skills</span>
|
||||
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
{skills.map((skill, index) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
added={skillsReady && existingNames.has(skill.name)}
|
||||
pending={pendingNames.has(skill.name)}
|
||||
disabled={pendingNames.has(skill.name) || !skillsReady}
|
||||
onAdd={() => handleAdd(skill, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { INTEGRATIONS } from '@/lib/integrations'
|
||||
import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail'
|
||||
import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ block: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { block } = await params
|
||||
const integration = INTEGRATIONS.find((i) => i.slug === block)
|
||||
return {
|
||||
title: integration ? `${integration.name} Integration` : 'Integration',
|
||||
}
|
||||
}
|
||||
|
||||
export default async function IntegrationBlockPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceId: string; block: string }>
|
||||
}) {
|
||||
const { workspaceId, block } = await params
|
||||
const integration = INTEGRATIONS.find((i) => i.slug === block)
|
||||
if (!integration) notFound()
|
||||
|
||||
return (
|
||||
<Suspense fallback={<IntegrationBlockDetailFallback workspaceId={workspaceId} />}>
|
||||
<IntegrationBlockDetail integration={integration} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { parseAsStringLiteral } from 'nuqs/server'
|
||||
import {
|
||||
CONNECT_MODE,
|
||||
CONNECT_QUERY_PARAM,
|
||||
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
|
||||
|
||||
const CONNECT_MODE_VALUES = [CONNECT_MODE.oauth, CONNECT_MODE.serviceAccount] as const
|
||||
|
||||
/**
|
||||
* Typed parser for the ephemeral `?connect=oauth|service-account` deep-link on
|
||||
* the integration detail page. The param is read once to pre-open the matching
|
||||
* connect modal, then stripped from the URL.
|
||||
*/
|
||||
export const connectParam = {
|
||||
key: CONNECT_QUERY_PARAM,
|
||||
parser: parseAsStringLiteral(CONNECT_MODE_VALUES),
|
||||
} as const
|
||||
Reference in New Issue
Block a user