Skip to content

Commit 1e3aa97

Browse files
committed
feat(integrations): operations list above templates on integration detail page
1 parent 118ad32 commit 1e3aa97

1 file changed

Lines changed: 125 additions & 35 deletions

File tree

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 125 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
'use client'
22

3+
import { type ReactNode, useMemo } from 'react'
34
import { ArrowLeft, ArrowRight } from 'lucide-react'
45
import { useRouter } from 'next/navigation'
56
import { ChipLink } from '@/components/emcn'
67
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
78
import { cn } from '@/lib/core/utils/cn'
89
import { blockTypeToIconMap, type Integration } from '@/lib/integrations'
910
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
10-
import { getTemplatesForBlock, type ScopedBlockTemplate } from '@/blocks/registry'
11+
import { getBlock, getTemplatesForBlock, type ScopedBlockTemplate } from '@/blocks/registry'
1112

1213
/** Maximum number of overlapping icon tiles rendered per template row. */
1314
const TEMPLATE_CLUSTER_MAX = 3 as const
@@ -26,6 +27,7 @@ interface IntegrationBlockDetailProps {
2627
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
2728
const Icon = blockTypeToIconMap[integration.type]
2829
const matchingTemplates = getTemplatesForBlock(integration.type)
30+
const operations = useMemo(() => getBlockOperations(integration.type), [integration.type])
2931

3032
return (
3133
<div className='flex h-full flex-col bg-[var(--bg)]'>
@@ -53,6 +55,10 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
5355
</div>
5456
</div>
5557

58+
{operations.length > 0 && (
59+
<OperationsSection integration={integration} operations={operations} />
60+
)}
61+
5662
{matchingTemplates.length > 0 && (
5763
<TemplatesSection
5864
integration={integration}
@@ -66,6 +72,54 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
6672
)
6773
}
6874

75+
interface IntegrationRowProps {
76+
leftIcon: ReactNode
77+
title: string
78+
subtitle?: string
79+
onClick?: () => void
80+
}
81+
82+
/**
83+
* Shared row primitive for the integration detail page's "Operations" and
84+
* "Templates" sections. Layout is identical in both modes — same `p-2`,
85+
* `gap-2.5`, same title typography (`text-[14px] text-[var(--text-body)]`) so
86+
* row heights line up regardless of whether a subtitle is present (the
87+
* 36px-tall `IntegrationTile` icon sets the minimum height).
88+
*
89+
* Interactive mode (when `onClick` is supplied) renders a `<button>` with a
90+
* `group` class enabling hover-state coordination with child icons, a
91+
* `hover-hover:` background, and a trailing `ArrowRight`. Read-only mode
92+
* renders a plain `<div>` with no hover affordance and no trailing chevron.
93+
*/
94+
function IntegrationRow({ leftIcon, title, subtitle, onClick }: IntegrationRowProps) {
95+
const content = (
96+
<>
97+
{leftIcon}
98+
<div className='flex min-w-0 flex-1 flex-col'>
99+
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
100+
{subtitle ? (
101+
<span className='truncate text-[12px] text-[var(--text-muted)]'>{subtitle}</span>
102+
) : null}
103+
</div>
104+
{onClick ? <ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' /> : null}
105+
</>
106+
)
107+
108+
if (onClick) {
109+
return (
110+
<button
111+
type='button'
112+
onClick={onClick}
113+
className='group flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
114+
>
115+
{content}
116+
</button>
117+
)
118+
}
119+
120+
return <div className='flex items-center gap-2.5 rounded-lg p-2'>{content}</div>
121+
}
122+
69123
interface TemplatesSectionProps {
70124
integration: Integration
71125
templates: readonly ScopedBlockTemplate[]
@@ -91,12 +145,12 @@ function TemplatesSection({ integration, templates, workspaceId }: TemplatesSect
91145
TEMPLATE_CLUSTER_MAX
92146
)
93147
return (
94-
<TemplateRow
148+
<IntegrationRow
95149
key={template.title}
96-
blockTypes={blockTypes}
150+
leftIcon={<TemplateIcons blockTypes={blockTypes} />}
97151
title={template.title}
98-
prompt={template.prompt}
99-
onSelect={handleSelect}
152+
subtitle={template.prompt}
153+
onClick={() => handleSelect(template.prompt)}
100154
/>
101155
)
102156
})}
@@ -105,36 +159,6 @@ function TemplatesSection({ integration, templates, workspaceId }: TemplatesSect
105159
)
106160
}
107161

108-
interface TemplateRowProps {
109-
blockTypes: string[]
110-
title: string
111-
prompt: string
112-
onSelect: (prompt: string) => void
113-
}
114-
115-
/**
116-
* Template row that mirrors `IntegrationItem` from the integrations index
117-
* byte-for-byte (icon cluster · title · description · trailing `ArrowRight`).
118-
* Renders as a `<button>` because click seeds the home page chat with `prompt`
119-
* and navigates to the workspace home, matching the `ShowcaseWithExplore` flow.
120-
*/
121-
function TemplateRow({ blockTypes, title, prompt, onSelect }: TemplateRowProps) {
122-
return (
123-
<button
124-
type='button'
125-
onClick={() => onSelect(prompt)}
126-
className='group flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
127-
>
128-
<TemplateIcons blockTypes={blockTypes} />
129-
<div className='flex min-w-0 flex-1 flex-col'>
130-
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
131-
<span className='truncate text-[12px] text-[var(--text-muted)]'>{prompt}</span>
132-
</div>
133-
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
134-
</button>
135-
)
136-
}
137-
138162
interface TemplateIconsProps {
139163
blockTypes: string[]
140164
}
@@ -177,3 +201,69 @@ function TemplateIcons({ blockTypes }: TemplateIconsProps) {
177201
</span>
178202
)
179203
}
204+
205+
interface OperationOption {
206+
label: string
207+
id: string
208+
}
209+
210+
/**
211+
* Extracts the operation list from a block's `subBlocks`. Looks for a dropdown
212+
* subBlock with `id === 'operation'` and returns its `options` flattened to
213+
* `{ label, id }`. Supports both static array `options` and function `options`,
214+
* filters out entries marked `hidden`. Returns an empty array when the block
215+
* has no operation selector or no visible options.
216+
*/
217+
function getBlockOperations(blockType: string): OperationOption[] {
218+
const block = getBlock(blockType)
219+
if (!block) return []
220+
const operationSubBlock = block.subBlocks.find(
221+
(sb) => sb.id === 'operation' && sb.type === 'dropdown'
222+
)
223+
if (!operationSubBlock?.options) return []
224+
const rawOptions =
225+
typeof operationSubBlock.options === 'function'
226+
? operationSubBlock.options()
227+
: operationSubBlock.options
228+
return rawOptions
229+
.filter((option) => !option.hidden)
230+
.map((option) => ({ label: option.label, id: option.id }))
231+
}
232+
233+
interface OperationsSectionProps {
234+
integration: Integration
235+
operations: OperationOption[]
236+
}
237+
238+
/**
239+
* Lists every operation exposed by the block's `operation` subBlock. Mirrors
240+
* `TemplatesSection`'s heading, divider, and outer spacing so the two sections
241+
* stack uniformly on the detail page. Each row reuses the same
242+
* `IntegrationRow` primitive as templates with no `onClick` and a single
243+
* `IntegrationTile` icon — no cluster, no chevron, no hover state.
244+
*/
245+
function OperationsSection({ integration, operations }: OperationsSectionProps) {
246+
const Icon = blockTypeToIconMap[integration.type]
247+
const leftIcon = Icon ? (
248+
<IntegrationTile blockType={integration.type} icon={Icon} />
249+
) : (
250+
<div
251+
className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] text-white'
252+
style={{ background: integration.bgColor }}
253+
>
254+
{integration.name.charAt(0)}
255+
</div>
256+
)
257+
258+
return (
259+
<section className='flex flex-col'>
260+
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Operations</span>
261+
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
262+
<div className='-mx-2 flex flex-col gap-y-0.5'>
263+
{operations.map((operation) => (
264+
<IntegrationRow key={operation.id} leftIcon={leftIcon} title={operation.label} />
265+
))}
266+
</div>
267+
</section>
268+
)
269+
}

0 commit comments

Comments
 (0)