Skip to content

Commit 290b4bf

Browse files
committed
feat(tables): clickable URL cells with favicons using tldts
1 parent 685ae54 commit 290b4bf

3 files changed

Lines changed: 163 additions & 353 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 19 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,14 @@
11
'use client'
22

33
import type React from 'react'
4+
import { parse } from 'tldts'
45
import { Badge, Checkbox, Tooltip } from '@/components/emcn'
56
import { cn } from '@/lib/core/utils/cn'
67
import type { RowExecutionMetadata } from '@/lib/table'
78
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
89
import { storageToDisplay } from '../../../utils'
910
import type { DisplayColumn } from '../types'
1011

11-
/**
12-
* Discriminated union describing every shape a table cell can take.
13-
*
14-
* Workflow-output cells follow a status state machine: they always render
15-
* *something* (a value, a status pill, or a dash), driven by the combination
16-
* of `executions[groupId]` state and dep satisfaction. Plain (non-workflow)
17-
* cells just render the typed value or empty.
18-
*
19-
* `'empty'` is the universal fallback used by both workflow cells (no exec,
20-
* no value, no waiting) and plain cells (null/undefined value).
21-
*
22-
* Adding a new cell appearance is a three-step mechanical change: add a
23-
* variant here, pick it in `resolveCellRender`, render it in `CellRender`.
24-
* TypeScript's exhaustiveness check on the renderer's `switch` (the
25-
* unreachable default) flags any branch you forgot.
26-
*/
2712
export type CellRenderKind =
2813
// Workflow-output cells
2914
| { kind: 'value'; text: string }
@@ -47,20 +32,9 @@ interface ResolveCellRenderInput {
4732
value: unknown
4833
exec: RowExecutionMetadata | undefined
4934
column: DisplayColumn
50-
/** Empty / undefined → not waiting; non-empty → render the Waiting pill. */
5135
waitingOnLabels: string[] | undefined
5236
}
5337

54-
/**
55-
* Decide which `CellRenderKind` to render for a cell. Pure — easily
56-
* unit-testable in isolation, no JSX involved.
57-
*
58-
* Order matters for workflow cells: block-error wins over a value (the user
59-
* cares about the failure), value wins over running/queued (we have data
60-
* already), and the running/queued branch deliberately collapses pre-enqueue
61-
* `pending` and post-enqueue `queued` into one `Queued` pill so the cell
62-
* doesn't flicker as the row transitions from one to the other.
63-
*/
6438
export function resolveCellRender({
6539
value,
6640
exec,
@@ -77,31 +51,20 @@ export function resolveCellRender({
7751

7852
if (blockError) return { kind: 'block-error' }
7953

80-
// Active re-run of THIS column wins over its prior value — the value is
81-
// about to be overwritten and the user should see the cell is changing.
8254
const inFlight =
8355
exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending'
8456
if (inFlight && blockRunning) return { kind: 'running' }
8557

86-
// Value wins over `pending-upstream`: once this column's output has
87-
// landed, the cell is done from the user's perspective — even if the
88-
// group is still running other blocks downstream. Without this, mid-run
89-
// partial-write events (`status: 'running'` carrying outputs but tagging
90-
// a different block as running) would flip a finished column back to the
91-
// amber Pending pill until the terminal `completed` event arrives.
58+
// Value wins over pending-upstream: a finished column stays finished even
59+
// while other blocks in the group are still running.
9260
if (!isNull) return { kind: 'value', text: stringifyValue(value) }
9361

9462
if (inFlight && !(groupHasBlockErrors && !blockRunning)) {
9563
if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' }
96-
// `running` with this block not in `runningBlockIds` and no value yet =
97-
// upstream block still going; surface as the amber Pending pill.
9864
return { kind: 'pending-upstream' }
9965
}
10066

101-
// Waiting wins over a stale terminal state: if deps are unmet right now,
102-
// the prior `cancelled` / `error` is informational at best — the cell
103-
// can't actually run until the user fills the missing input. Surface the
104-
// actionable state instead of the stale one.
67+
// Waiting wins over a stale terminal status — show the actionable state.
10568
if (waitingOnLabels && waitingOnLabels.length > 0) {
10669
return { kind: 'waiting', labels: waitingOnLabels }
10770
}
@@ -129,91 +92,32 @@ function stringifyValue(value: unknown): string {
12992
return JSON.stringify(value)
13093
}
13194

132-
/** Matches bare hostnames: `microsoft.com`, `www.linkedin.com`, `sub.domain.co.uk` */
13395
const BARE_DOMAIN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
13496

135-
/**
136-
* File extensions that are also valid TLDs but should never be treated as URLs.
137-
* Without this, strings like `config.json` or `readme.md` would resolve as domains.
138-
*/
139-
const FILE_EXTENSION_TLDS = new Set([
140-
'txt',
141-
'json',
142-
'yaml',
143-
'yml',
144-
'xml',
145-
'html',
146-
'htm',
147-
'css',
148-
'js',
149-
'ts',
150-
'tsx',
151-
'jsx',
152-
'md',
153-
'mdx',
154-
'csv',
155-
'pdf',
156-
'doc',
157-
'docx',
158-
'xls',
159-
'xlsx',
160-
'ppt',
161-
'pptx',
162-
'zip',
163-
'gz',
164-
'tar',
165-
'rar',
166-
'png',
167-
'jpg',
168-
'jpeg',
169-
'gif',
170-
'svg',
171-
'webp',
172-
'mp4',
173-
'mp3',
174-
'wav',
175-
'mov',
176-
'py',
177-
'rb',
178-
'go',
179-
'rs',
180-
'sh',
181-
'bat',
182-
'log',
183-
'env',
184-
])
185-
18697
function extractUrlInfo(text: string): { href: string; domain: string } | null {
187-
if (!text) return null
188-
if (/^https?:\/\//i.test(text)) {
98+
const trimmed = text.trim()
99+
if (!trimmed) return null
100+
if (/^https?:\/\//i.test(trimmed)) {
189101
try {
190-
const url = new URL(text)
191-
return { href: text, domain: url.hostname }
102+
const url = new URL(trimmed)
103+
return { href: trimmed, domain: url.hostname }
192104
} catch {
193105
return null
194106
}
195107
}
196-
if (BARE_DOMAIN_RE.test(text)) {
197-
const tld = text.split('.').pop()?.toLowerCase() ?? ''
198-
if (FILE_EXTENSION_TLDS.has(tld)) return null
199-
return { href: `https://${text}`, domain: text }
108+
if (BARE_DOMAIN_RE.test(trimmed)) {
109+
const parsed = parse(trimmed)
110+
if (!parsed.isIcann) return null
111+
return { href: `https://${trimmed}`, domain: trimmed }
200112
}
201113
return null
202114
}
203115

204116
interface CellRenderProps {
205117
kind: CellRenderKind
206-
/** When true the static content sits underneath the InlineEditor overlay
207-
* and should be visually hidden (but kept in flow to preserve cell size). */
208118
isEditing: boolean
209119
}
210120

211-
/**
212-
* Pure renderer: takes a `CellRenderKind` and returns the JSX. No business
213-
* logic — adding a new cell appearance means adding a new `case` here. The
214-
* exhaustiveness check on the `switch` (the unreachable default) flags any
215-
* variant you forgot to handle.
216-
*/
217121
export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null {
218122
switch (kind.kind) {
219123
case 'value':
@@ -320,7 +224,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
320224
return (
321225
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
322226
<img
323-
src={`https://www.google.com/s2/favicons?domain=${kind.domain}&sz=16`}
227+
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(kind.domain)}&sz=16`}
324228
alt=''
325229
width={12}
326230
height={12}
@@ -333,8 +237,12 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
333237
href={kind.href}
334238
target='_blank'
335239
rel='noopener noreferrer'
336-
className='min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70'
240+
className={cn(
241+
'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70',
242+
isEditing && 'pointer-events-none'
243+
)}
337244
onClick={(e) => e.stopPropagation()}
245+
onDoubleClick={(e) => e.stopPropagation()}
338246
>
339247
{kind.text}
340248
</a>
@@ -357,20 +265,12 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
357265
return null
358266

359267
default: {
360-
// Exhaustiveness guard: TypeScript flags this branch if a new
361-
// `CellRenderKind` variant is added without a matching `case` above.
362268
const _exhaustive: never = kind
363269
return _exhaustive
364270
}
365271
}
366272
}
367273

368-
/**
369-
* Workflow-output cells are hand-editable; while editing, the static content
370-
* must stay in flow (so the cell doesn't collapse) but be visually hidden so
371-
* the InlineEditor overlay shows through. Plain wrapper around any non-text
372-
* variant.
373-
*/
374274
function Wrap({ isEditing, children }: { isEditing: boolean; children: React.ReactNode }) {
375275
if (!isEditing) return <>{children}</>
376276
return <div className='invisible'>{children}</div>

apps/sim/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@
195195
"tailwind-merge": "^2.6.0",
196196
"tailwindcss-animate": "^1.0.7",
197197
"three": "0.177.0",
198+
"tldts": "7.0.30",
198199
"twilio": "5.9.0",
199200
"unified": "11.0.5",
200201
"unpdf": "1.4.0",

0 commit comments

Comments
 (0)