Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/sim/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ bun-debug.log*

# testing
/coverage
/e2e/.cache/
/e2e/.runs/
/playwright-report/
/test-results/

# next.js
/.next/
Expand Down Expand Up @@ -45,4 +49,4 @@ next-env.d.ts
# Uploads
/uploads

.trigger
.trigger
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @vitest-environment jsdom
*/

import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

const { tableMock } = vi.hoisted(() => ({ tableMock: vi.fn() }))

vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/table', () => ({
Table: (props: Record<string, unknown>) => {
tableMock(props)
return <div data-testid='embedded-table' />
},
}))

import { EmbeddedViewTable } from './embedded-view-table'

let container: HTMLDivElement
let root: Root

describe('EmbeddedViewTable', () => {
beforeEach(() => {
vi.clearAllMocks()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

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

it('renders the source Table with the persisted View selected', async () => {
await act(async () => {
root.render(
<EmbeddedViewTable workspaceId='workspace_1' resourceId='tbl_1:view_1' viewsEnabled />
)
})

expect(container.querySelector('[data-testid="embedded-table"]')).not.toBeNull()
expect(tableMock).toHaveBeenCalledWith({
workspaceId: 'workspace_1',
tableId: 'tbl_1',
viewId: 'view_1',
embedded: true,
viewsEnabled: true,
})
})

it('does not render a malformed View resource', async () => {
await act(async () => {
root.render(<EmbeddedViewTable workspaceId='workspace_1' resourceId='view_1' />)
})

expect(container.innerHTML).toBe('')
expect(tableMock).not.toHaveBeenCalled()
})

it('does not bypass a disabled Views feature flag', async () => {
await act(async () => {
root.render(<EmbeddedViewTable workspaceId='workspace_1' resourceId='tbl_1:view_1' />)
})

expect(container.innerHTML).toBe('')
expect(tableMock).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client'

import { parseTableViewResourceId } from '@/lib/copilot/resources/types'
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table'

interface EmbeddedViewTableProps {
workspaceId: string
resourceId: string
viewsEnabled?: boolean
}

/** Renders a persisted View resource through its live source Table. */
export function EmbeddedViewTable({
workspaceId,
resourceId,
viewsEnabled = false,
}: EmbeddedViewTableProps) {
const parsed = parseTableViewResourceId(resourceId)
if (!parsed || !viewsEnabled) return null
return (
<Table
workspaceId={workspaceId}
tableId={parsed.tableId}
viewId={parsed.viewId}
embedded
viewsEnabled={viewsEnabled}
/>
Comment thread
cursor[bot] marked this conversation as resolved.
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { EmbeddedViewTable } from './embedded-view-table'
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
resolveFileCategory,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session'
import { EmbeddedViewTable } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table'
import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content'
import { TerminalSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session'
import {
Expand Down Expand Up @@ -237,6 +238,16 @@ export const ResourceContent = memo(function ResourceContent({
/>
)

case 'view':
return (
<EmbeddedViewTable
key={resource.id}
workspaceId={workspaceId}
resourceId={resource.id}
viewsEnabled={tableViewsEnabled}
/>
)

case 'file':
return (
<EmbeddedFile
Expand Down Expand Up @@ -337,6 +348,8 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
tableName={resource.title}
/>
)
case 'view':
return null
case 'log':
return <EmbeddedLogActions workspaceId={workspaceId} logId={resource.id} />
case 'scheduledtask':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Connections,
Cursor,
Database,
Eye,
File as FileIcon,
Folder as FolderIcon,
Library,
Expand All @@ -17,6 +18,7 @@ import {
} from '@sim/emcn/icons'
import type { QueryClient } from '@tanstack/react-query'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { parseTableViewResourceId } from '@/lib/copilot/resources/types'
import type {
MothershipResource,
MothershipResourceType,
Expand Down Expand Up @@ -140,6 +142,15 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
},
view: {
type: 'view',
label: 'Views',
icon: Eye,
renderTabIcon: (_resource, className) => (
<Eye className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Eye} />,
},
file: {
type: 'file',
label: 'Files',
Expand Down Expand Up @@ -249,6 +260,12 @@ const RESOURCE_INVALIDATORS: Record<
qc.invalidateQueries({ queryKey: tableKeys.lists() })
qc.invalidateQueries({ queryKey: tableKeys.detail(id) })
},
view: (qc, _wId, id) => {
const parsed = parseTableViewResourceId(id)
if (!parsed) return
qc.invalidateQueries({ queryKey: tableKeys.detail(parsed.tableId) })
qc.invalidateQueries({ queryKey: tableKeys.views(parsed.tableId) })
},
file: (qc, wId, id) => {
qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cn } from '@sim/emcn'
import { parseTableViewResourceId } from '@/lib/copilot/resources/types'
import type {
MothershipResource,
MothershipResourceType,
Expand Down Expand Up @@ -122,6 +123,11 @@ const RESOURCE_TO_CONTEXT: Record<
workflow: (r) => ({ kind: 'workflow', workflowId: r.id, label: r.title }),
knowledgebase: (r) => ({ kind: 'knowledge', knowledgeId: r.id, label: r.title }),
table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }),
view: (r) => ({
kind: 'table',
tableId: parseTableViewResourceId(r.id)?.tableId ?? r.id,
label: r.title,
}),
file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }),
folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }),
filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,12 @@ describe('mapResourceToContext', () => {
tableId: 't-1',
label: 'Leads',
})
expect(
mapResourceToContext(resource({ type: 'view', id: 't-1:v-1', title: 'Qualified leads' }))
).toEqual({
kind: 'table',
tableId: 't-1',
label: 'Qualified leads',
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @vitest-environment jsdom
*/

import type { ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableViewWire } from '@/lib/api/contracts/tables'

;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

vi.mock('@sim/emcn', () => ({
ChipChevronDown: () => null,
chipContentLabelClass: '',
chipVariants: () => '',
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
POPOVER_ANIMATION_CLASSES: '',
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverAnchor: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
PopoverSection: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))

import { ViewsMenu } from './views-menu'

function view(id: string, name: string): TableViewWire {
return {
id,
tableId: 'tbl_1',
name,
config: {},
isDefault: false,
createdBy: 'user_1',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
}
}

let container: HTMLDivElement
let root: Root

describe('ViewsMenu', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

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

it('renders the implicit Default view first and selects saved Views', async () => {
const onSelect = vi.fn()
await act(async () => {
root.render(
<ViewsMenu
views={[view('view_a', 'Alpha'), view('view_z', 'Zulu')]}
activeViewId={null}
onSelect={onSelect}
onRename={vi.fn()}
onDelete={vi.fn()}
onNewView={vi.fn()}
canEdit
/>
)
})

const labels = Array.from(container.querySelectorAll('button'))
.map((button) => button.textContent?.trim())
.filter(Boolean)
expect(labels.slice(1, 4)).toEqual(['Default view', 'Alpha', 'Zulu'])

const alpha = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Alpha'
)
await act(async () => alpha?.click())
expect(onSelect).toHaveBeenCalledWith('view_a')
})

it('exposes rename and delete actions only for saved Views', async () => {
const onRename = vi.fn()
const onDelete = vi.fn()
await act(async () => {
root.render(
<ViewsMenu
views={[view('view_a', 'Alpha')]}
activeViewId='view_a'
onSelect={vi.fn()}
onRename={onRename}
onDelete={onDelete}
onNewView={vi.fn()}
canEdit
/>
)
})

const rename = container.querySelector<HTMLButtonElement>('button[aria-label="Rename"]')
const remove = container.querySelector<HTMLButtonElement>('button[aria-label="Delete"]')
expect(rename).not.toBeNull()
expect(remove).not.toBeNull()

await act(async () => rename?.click())
await act(async () => remove?.click())
expect(onRename).toHaveBeenCalledWith('view_a')
expect(onDelete).toHaveBeenCalledWith('view_a')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { Check, Pencil, Plus, Trash } from '@sim/emcn/icons'
import type { TableViewWire } from '@/lib/api/contracts/tables'

/** Label for the built-in unfiltered state. Not a stored row — `null` view id. */
export const ALL_ROWS_VIEW_LABEL = 'All'
export const ALL_ROWS_VIEW_LABEL = 'Default view'

/** Matches the breadcrumb location popover's hover-intent grace period. */
const POPOVER_CLOSE_DELAY_MS = 120
Expand Down Expand Up @@ -60,7 +60,7 @@ export const ViewsMenu = memo(function ViewsMenu({
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

const activeView = activeViewId ? views.find((view) => view.id === activeViewId) : undefined
const label = activeView?.name ?? 'View'
const label = activeView?.name ?? ALL_ROWS_VIEW_LABEL

const cancelScheduledClose = () => {
if (closeTimeoutRef.current) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type {
WorkflowGroup,
} from '@/lib/table'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { pruneFilterForColumns } from '@/lib/table/query-builder/converters'
import {
pruneFilterForColumns,
pruneViewFilterForColumns,
} from '@/lib/table/query-builder/converters'
import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs'
import { getBlock } from '@/blocks'
import {
Expand Down Expand Up @@ -97,10 +100,11 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
// server rejects outright, which would fail every subsequent rows query. Prune
// here, above every consumer of the rows query key, so the paged helpers below
// can't rebuild the key from the unpruned filter and drift.
const filter = useMemo(
() => pruneFilterForColumns(queryOptions.filter ?? null, tableData?.schema?.columns ?? []),
[queryOptions.filter, tableData?.schema?.columns]
)
const filter = useMemo(() => {
const columns = tableData?.schema?.columns ?? []
const compatible = pruneFilterForColumns(queryOptions.filter ?? null, columns)
return columns.length > 0 ? pruneViewFilterForColumns(compatible, columns) : compatible
}, [queryOptions.filter, tableData?.schema?.columns])

const {
data: rowsData,
Expand Down
Loading
Loading