Skip to content
Open
71 changes: 71 additions & 0 deletions apps/sim/app/api/table/names/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @vitest-environment node
*/

import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
listNames: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
vi.mock('@/lib/table/application/operations', () => ({
tableOperations: { list: { id: 'tables.list' } },
}))
vi.mock('@/lib/table/application/tables', () => ({
listTableNamesUseCase: { operation: { id: 'tables.list' }, execute: mocks.listNames },
}))

import { POST } from '@/app/api/table/names/route'

function request(body?: unknown) {
return createMockRequest('POST', body, {}, 'http://localhost/api/table/names')
}

describe('POST /api/table/names', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mocks.listNames.mockResolvedValue({
tables: [{ id: 'table-1', name: 'Accounts' }],
})
})

it('returns the lightweight table-name projection', async () => {
const response = await POST(
request({ workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] }),
{}
)

expect(response.status).toBe(200)
expect(mocks.listNames.mock.calls[0][0]).toMatchObject({
principal: { kind: 'session', userId: 'user-1' },
input: { workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] },
})
expect(await response.json()).toEqual({
success: true,
data: { tables: [{ id: 'table-1', name: 'Accounts' }] },
})
})

it('authenticates before validating the body', async () => {
mocks.getSession.mockResolvedValue(null)

const response = await POST(request(), {})

expect(response.status).toBe(401)
expect(mocks.listNames).not.toHaveBeenCalled()
})

it('rejects an empty table ID list', async () => {
const response = await POST(request({ workspaceId: 'workspace-1', tableIds: [] }), {})

expect(response.status).toBe(400)
expect(mocks.listNames).not.toHaveBeenCalled()
})
})
22 changes: 22 additions & 0 deletions apps/sim/app/api/table/names/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { listTableNamesContract } from '@/lib/api/contracts/tables'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { tableOperations } from '@/lib/table/application/operations'
import { listTableNamesUseCase } from '@/lib/table/application/tables'

export const POST = defineInternalJsonRoute({
contract: listTableNamesContract,
operation: tableOperations.list,
auth: internalSessionAuth,
rateLimit: internalRateLimits.none({
reason: 'Preserve existing internal table list behavior',
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ body }) => body,
useCase: listTableNamesUseCase,
present: ({ tables }) => ({ success: true as const, data: { tables } }),
})
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
'use client'

import type { RowExecutionMetadata } from '@/lib/table'
import {
CellRender,
type ReferenceCellAction,
resolveCellRender,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render'
import { InlineEditor } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors'
import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'
import type { SaveReason } from '@/app/workspace/[workspaceId]/tables/[tableId]/types'
import type { TimezoneState } from '@/hooks/queries/general-settings'
import type { SaveReason } from '../../../types'
import type { DisplayColumn } from '../types'
import { CellRender, resolveCellRender } from './cell-render'
import { InlineEditor } from './inline-editors'

interface CellContentProps {
value: unknown
Expand All @@ -15,6 +19,7 @@ interface CellContentProps {
* URL render as a tagged-resource chip instead of a plain external link. */
workspaceId: string
timezoneStatus: TimezoneState['status']
referenceColumnsEnabled: boolean
isEditing: boolean
initialCharacter?: string | null
/** Opens the inline editor read-only; text stays selectable and copyable. */
Expand All @@ -29,6 +34,7 @@ interface CellContentProps {
waitingOnLabels?: string[]
/** Column is an enrichment output — a completed-but-empty cell renders "Not found". */
isEnrichmentOutput?: boolean
referenceAction?: ReferenceCellAction
}

/**
Expand All @@ -43,13 +49,15 @@ export function CellContent({
column,
workspaceId,
timezoneStatus,
referenceColumnsEnabled,
isEditing,
initialCharacter,
readOnly,
onSave,
onCancel,
waitingOnLabels,
isEnrichmentOutput,
referenceAction,
}: CellContentProps) {
const kind = resolveCellRender({
value,
Expand All @@ -59,6 +67,7 @@ export function CellContent({
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
timezoneStatus,
referenceColumnsEnabled,
})

return (
Expand All @@ -75,7 +84,7 @@ export function CellContent({
/>
</div>
)}
<CellRender kind={kind} isEditing={isEditing} />
<CellRender kind={kind} isEditing={isEditing} referenceAction={referenceAction} />
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('resolveCellRender', () => {
exec: undefined,
column: column('ttl'),
waitingOnLabels: undefined,
referenceColumnsEnabled: false,
timezoneStatus,
})
expect(kind).toEqual({ kind: 'text', text: value })
Expand All @@ -48,6 +49,7 @@ describe('resolveCellRender', () => {
exec: undefined,
column: column('date'),
waitingOnLabels: undefined,
referenceColumnsEnabled: false,
timezoneStatus: 'error',
})
expect(kind).toEqual({ kind: 'date', text: stored, raw: true })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'

vi.mock('@sim/emcn', () => ({
Badge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Button: ({
children,
size,
variant,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
size?: string
variant?: string
}) => (
<button data-size={size} data-variant={variant} {...props}>
{children}
</button>
),
Checkbox: () => null,
ChipTag: ({
children,
variant,
...props
}: React.HTMLAttributes<HTMLSpanElement> & { variant?: string }) => (
<span data-chip-tag-variant={variant} {...props}>
{children}
</span>
),
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
Tooltip: {
Root: ({ children }: { children: React.ReactNode }) => children,
Trigger: ({ children }: { children: React.ReactNode }) => children,
Content: ({ children }: { children: React.ReactNode }) => children,
},
}))

vi.mock('@/app/workspace/[workspaceId]/logs/utils', () => ({
StatusBadge: () => null,
}))

vi.mock(
'@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell',
() => ({ SimResourceCell: () => null })
)

vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({
resolveSelectOptions: () => [],
SelectPill: () => null,
}))

import {
CellRender,
resolveCellRender,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render'

const REFERENCE_COLUMN: DisplayColumn = {
id: 'col-account',
key: 'col-account',
name: 'Account',
type: 'reference',
referenceTableId: 'table-accounts',
referenceTableName: 'Accounts',
groupSize: 1,
groupStartColIndex: 0,
headerLabel: 'Account',
isGroupStart: true,
}

let container: HTMLDivElement
let root: Root

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
act(() => {
root = createRoot(container)
})
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

describe('reference cell rendering', () => {
it('resolves a stored row ID to a chip labeled with the referenced table name', () => {
expect(
resolveCellRender({
value: 'row-account-1',
exec: undefined,
column: REFERENCE_COLUMN,
waitingOnLabels: undefined,
referenceColumnsEnabled: true,
})
).toEqual({ kind: 'reference-chip', label: 'Accounts' })
})

it('keeps an empty reference cell empty', () => {
expect(
resolveCellRender({
value: '',
exec: undefined,
column: REFERENCE_COLUMN,
waitingOnLabels: undefined,
referenceColumnsEnabled: true,
})
).toEqual({ kind: 'empty' })
})

it('uses a neutral label while the referenced table name is unavailable', () => {
expect(
resolveCellRender({
value: 'row-account-1',
exec: undefined,
column: { ...REFERENCE_COLUMN, referenceTableName: undefined },
waitingOnLabels: undefined,
referenceColumnsEnabled: true,
})
).toEqual({ kind: 'reference-chip', label: 'Referenced table' })
})

it('renders the stored row ID as plain text when the feature is disabled', () => {
expect(
resolveCellRender({
value: 'row-account-1',
exec: undefined,
column: REFERENCE_COLUMN,
waitingOnLabels: undefined,
referenceColumnsEnabled: false,
})
).toEqual({ kind: 'text', text: 'row-account-1' })
})

it('opens the referenced row from the chip without exposing its stored row ID', () => {
const onReferenceClick = vi.fn()

act(() => {
root.render(
<CellRender
kind={resolveCellRender({
value: 'row-account-1',
exec: undefined,
column: REFERENCE_COLUMN,
waitingOnLabels: undefined,
referenceColumnsEnabled: true,
})}
isEditing={false}
referenceAction={{ expanded: false, onClick: onReferenceClick }}
/>
)
})

const chip = container.querySelector('button')
expect(chip?.textContent).toBe('Accounts')
expect(chip?.dataset.variant).toBe('ghost')
expect(chip?.dataset.size).toBe('sm')
expect(chip).toHaveProperty('dataset.referenceCellTrigger', '')
expect(chip?.className).toContain('max-w-full')
expect(chip?.className).toContain('p-0')
expect(chip?.querySelector('svg')).toBeNull()
const tag = chip?.querySelector('[data-chip-tag-variant="field"]')
expect(tag?.textContent).toBe('Accounts')
expect(tag?.className).toContain('min-w-0')
expect(tag?.className).toContain('max-w-full')

act(() => chip?.click())

expect(onReferenceClick).toHaveBeenCalledOnce()
expect(container.textContent).not.toContain('row-account-1')
})

it('keeps a chip double-click from reaching the reference cell', () => {
const onCellDoubleClick = vi.fn()
const onReferenceClick = vi.fn()

act(() => {
root.render(
<div onDoubleClick={onCellDoubleClick}>
<CellRender
kind={{ kind: 'reference-chip', label: 'Accounts' }}
isEditing={false}
referenceAction={{ expanded: false, onClick: onReferenceClick }}
/>
</div>
)
})

act(() => {
const chip = container.querySelector('button')
chip?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))
chip?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 }))
chip?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, detail: 2 }))
})

expect(onReferenceClick).toHaveBeenCalledOnce()
expect(onCellDoubleClick).not.toHaveBeenCalled()
})
})
Loading
Loading