Skip to content

Commit f9e8162

Browse files
committed
feat(tables): add row ID copy and reference navigation
1 parent bab5ffc commit f9e8162

6 files changed

Lines changed: 281 additions & 3 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@sim/emcn', () => ({
9+
DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) =>
10+
open ? <>{children}</> : null,
11+
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
12+
DropdownMenuItem: ({
13+
children,
14+
disabled,
15+
onSelect,
16+
}: {
17+
children: ReactNode
18+
disabled?: boolean
19+
onSelect?: () => void
20+
}) => (
21+
<button type='button' disabled={disabled} onClick={onSelect}>
22+
{children}
23+
</button>
24+
),
25+
DropdownMenuSeparator: () => <hr />,
26+
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
27+
}))
28+
29+
vi.mock('@sim/emcn/icons', () => ({
30+
ArrowDown: () => null,
31+
ArrowUp: () => null,
32+
Blimp: () => null,
33+
Duplicate: () => null,
34+
Eye: () => null,
35+
ListFilter: () => null,
36+
Pencil: () => null,
37+
PlayOutline: () => null,
38+
RefreshCw: () => null,
39+
Square: () => null,
40+
Trash: () => null,
41+
}))
42+
43+
import { ContextMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu'
44+
45+
let container: HTMLDivElement
46+
let root: Root
47+
48+
beforeEach(() => {
49+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
50+
container = document.createElement('div')
51+
document.body.appendChild(container)
52+
act(() => {
53+
root = createRoot(container)
54+
})
55+
})
56+
57+
afterEach(() => {
58+
act(() => root.unmount())
59+
container.remove()
60+
})
61+
62+
function findButton(label: string): HTMLButtonElement | undefined {
63+
return Array.from(container.querySelectorAll('button')).find(
64+
(button) => button.textContent?.trim() === label
65+
)
66+
}
67+
68+
describe('table row ContextMenu', () => {
69+
it('places Copy Row Id directly below Duplicate row and invokes its handler', () => {
70+
const onCopyRowId = vi.fn()
71+
72+
act(() => {
73+
root.render(
74+
<ContextMenu
75+
contextMenu={{
76+
isOpen: true,
77+
position: { x: 0, y: 0 },
78+
row: { id: 'row-1', data: {}, position: 'a0' },
79+
rowIndex: 0,
80+
columnName: null,
81+
}}
82+
onClose={vi.fn()}
83+
onEditCell={vi.fn()}
84+
onDelete={vi.fn()}
85+
onInsertAbove={vi.fn()}
86+
onInsertBelow={vi.fn()}
87+
onDuplicate={vi.fn()}
88+
onCopyRowId={onCopyRowId}
89+
/>
90+
)
91+
})
92+
93+
const labels = Array.from(container.querySelectorAll('button')).map((button) =>
94+
button.textContent?.trim()
95+
)
96+
expect(labels.indexOf('Copy Row Id')).toBe(labels.indexOf('Duplicate row') + 1)
97+
98+
act(() => findButton('Copy Row Id')?.click())
99+
expect(onCopyRowId).toHaveBeenCalledOnce()
100+
})
101+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ interface ContextMenuProps {
3535
onInsertAbove: () => void
3636
onInsertBelow: () => void
3737
onDuplicate: () => void
38+
/** Copies the stable id of the row that opened the menu. Omit for an empty grid slot. */
39+
onCopyRowId?: () => void
3840
onViewExecution?: () => void
3941
canViewExecution?: boolean
4042
canEditCell?: boolean
@@ -95,6 +97,7 @@ export function ContextMenu({
9597
onInsertAbove,
9698
onInsertBelow,
9799
onDuplicate,
100+
onCopyRowId,
98101
onViewExecution,
99102
canViewExecution = false,
100103
canEditCell = true,
@@ -253,6 +256,12 @@ export function ContextMenu({
253256
<Duplicate />
254257
Duplicate row
255258
</DropdownMenuItem>
259+
{onCopyRowId && (
260+
<DropdownMenuItem onSelect={onCopyRowId}>
261+
<Duplicate />
262+
Copy Row Id
263+
</DropdownMenuItem>
264+
)}
256265
<DropdownMenuSeparator />
257266
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
258267
<Trash />

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface ColumnHeaderMenuProps {
2424
onColumnSelect: (colIndex: number, shiftKey: boolean) => void
2525
onInsertLeft: (columnName: string) => void
2626
onInsertRight: (columnName: string) => void
27+
/** Opens the table targeted by a Reference column. */
28+
onGoToReferenceTable?: (tableId: string) => void
2729
onDeleteColumn: (columnName: string) => void
2830
onResizeStart: (columnKey: string) => void
2931
onResize: (columnKey: string, width: number) => void
@@ -74,6 +76,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
7476
onColumnSelect,
7577
onInsertLeft,
7678
onInsertRight,
79+
onGoToReferenceTable,
7780
onDeleteColumn,
7881
onResizeStart,
7982
onResize,
@@ -346,6 +349,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
346349
column={column}
347350
deleteLabel={deleteLabel}
348351
onOpenConfig={onOpenConfig}
352+
onGoToReferenceTable={onGoToReferenceTable}
349353
onInsertLeft={onInsertLeft}
350354
onInsertRight={onInsertRight}
351355
onDeleteColumn={onDeleteColumn}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { ColumnDefinition } from '@/lib/table'
8+
9+
vi.mock('@sim/emcn', () => ({
10+
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
11+
DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) =>
12+
open ? <>{children}</> : null,
13+
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
14+
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
15+
<button type='button' onClick={onSelect}>
16+
{children}
17+
</button>
18+
),
19+
DropdownMenuSeparator: () => <hr />,
20+
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
21+
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}</>,
22+
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>,
23+
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
24+
}))
25+
26+
vi.mock('@sim/emcn/icons', () => ({
27+
ArrowDown: () => null,
28+
ArrowLeft: () => null,
29+
ArrowRight: () => null,
30+
ArrowUp: () => null,
31+
Eye: () => null,
32+
EyeOff: () => null,
33+
Fingerprint: () => null,
34+
Pencil: () => null,
35+
Pin: () => null,
36+
PinOff: () => null,
37+
PlayOutline: () => null,
38+
Settings: () => null,
39+
SquareArrowUpRight: () => null,
40+
Trash: () => null,
41+
Workflow: () => null,
42+
X: () => null,
43+
}))
44+
45+
vi.mock('@/lib/table/column-types', () => ({
46+
columnTypeOf: (column: ColumnDefinition) => ({
47+
icon: () => null,
48+
label: column.type === 'reference' ? 'Reference' : 'Text',
49+
hasConfiguration: column.type === 'reference',
50+
}),
51+
}))
52+
53+
vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar', () => ({
54+
PLAIN_COLUMN_TYPE_OPTIONS: [],
55+
}))
56+
57+
vi.mock('@/enrichments/registry', () => ({ getEnrichment: () => undefined }))
58+
59+
import { ColumnOptionsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell'
60+
61+
let container: HTMLDivElement
62+
let root: Root
63+
64+
beforeEach(() => {
65+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
66+
container = document.createElement('div')
67+
document.body.appendChild(container)
68+
act(() => {
69+
root = createRoot(container)
70+
})
71+
})
72+
73+
afterEach(() => {
74+
act(() => root.unmount())
75+
container.remove()
76+
})
77+
78+
function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) {
79+
act(() => {
80+
root.render(
81+
<ColumnOptionsMenu
82+
open
83+
onOpenChange={vi.fn()}
84+
position={{ x: 0, y: 0 }}
85+
column={{
86+
...column,
87+
key: column.id ?? column.name,
88+
groupSize: 1,
89+
groupStartColIndex: 0,
90+
headerLabel: column.name,
91+
isGroupStart: true,
92+
}}
93+
onInsertLeft={vi.fn()}
94+
onInsertRight={vi.fn()}
95+
onDeleteColumn={vi.fn()}
96+
onGoToReferenceTable={onGoToReferenceTable}
97+
/>
98+
)
99+
})
100+
}
101+
102+
function findButton(label: string): HTMLButtonElement | undefined {
103+
return Array.from(container.querySelectorAll('button')).find(
104+
(button) => button.textContent?.trim() === label
105+
)
106+
}
107+
108+
describe('ColumnOptionsMenu Reference navigation', () => {
109+
it('opens the table targeted by a Reference column', () => {
110+
const onGoToReferenceTable = vi.fn()
111+
renderMenu(
112+
{
113+
id: 'col-account',
114+
name: 'Account',
115+
type: 'reference',
116+
referenceTableId: 'table-accounts',
117+
},
118+
onGoToReferenceTable
119+
)
120+
121+
act(() => findButton('Go to Reference Table')?.click())
122+
123+
expect(onGoToReferenceTable).toHaveBeenCalledWith('table-accounts')
124+
})
125+
126+
it('does not show the action for a non-Reference column', () => {
127+
renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn())
128+
129+
expect(findButton('Go to Reference Table')).toBeUndefined()
130+
})
131+
132+
it('does not show the action when Reference metadata has no target table', () => {
133+
renderMenu({ id: 'col-account', name: 'Account', type: 'reference' }, vi.fn())
134+
135+
expect(findButton('Go to Reference Table')).toBeUndefined()
136+
})
137+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Pin,
2525
PinOff,
2626
PlayOutline,
27+
SquareArrowUpRight,
2728
Trash,
2829
Workflow,
2930
X,
@@ -66,10 +67,12 @@ interface ColumnOptionsMenuProps {
6667
column: DisplayColumn
6768
/** Override for the destructive item's label. Defaults to "Delete column"
6869
* for both plain columns and workflow groups. Use "Hide column" when the
69-
* destructive action is non-lossy (workflow-output column where removing
70-
* it leaves the group with siblings). */
70+
* destructive action is non-lossy (workflow-output column where removing
71+
* it leaves the group with siblings). */
7172
deleteLabel?: string
7273
onOpenConfig: (columnName: string) => void
74+
/** Opens the table targeted by a Reference column. */
75+
onGoToReferenceTable?: (tableId: string) => void
7376
onInsertLeft: (columnName: string) => void
7477
onInsertRight: (columnName: string) => void
7578
onDeleteColumn: (columnName: string) => void
@@ -122,6 +125,7 @@ export function ColumnOptionsMenu({
122125
column,
123126
deleteLabel,
124127
onOpenConfig,
128+
onGoToReferenceTable,
125129
onInsertLeft,
126130
onInsertRight,
127131
onDeleteColumn,
@@ -142,6 +146,7 @@ export function ColumnOptionsMenu({
142146
const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete)
143147
const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0
144148
const runLabels = runMenuLabels(hasActiveFilter)
149+
const referenceTableId = column.type === 'reference' ? column.referenceTableId : undefined
145150
return (
146151
<DropdownMenu open={open} onOpenChange={onOpenChange}>
147152
<DropdownMenuTrigger asChild>
@@ -228,6 +233,12 @@ export function ColumnOptionsMenu({
228233
View workflow
229234
</DropdownMenuItem>
230235
)}
236+
{referenceTableId && onGoToReferenceTable && (
237+
<DropdownMenuItem onSelect={() => onGoToReferenceTable(referenceTableId)}>
238+
<SquareArrowUpRight />
239+
Go to Reference Table
240+
</DropdownMenuItem>
241+
)}
231242
<DropdownMenuItem onSelect={() => onOpenConfig(column.key)}>
232243
<Pencil />
233244
Edit column

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createLogger } from '@sim/logger'
88
import type { TableCellSelection } from '@sim/realtime-protocol/table-presence'
99
import { getErrorMessage } from '@sim/utils/errors'
1010
import { useVirtualizer } from '@tanstack/react-virtual'
11-
import { useParams } from 'next/navigation'
11+
import { useParams, useRouter } from 'next/navigation'
1212
import { usePostHog } from 'posthog-js/react'
1313
import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables'
1414
import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard'
@@ -472,6 +472,7 @@ export function TableGrid({
472472
const params = useParams()
473473
const workspaceId = propWorkspaceId || (params.workspaceId as string)
474474
const tableId = propTableId || (params.tableId as string)
475+
const router = useRouter()
475476
const posthog = usePostHog()
476477

477478
useEffect(() => {
@@ -1698,6 +1699,19 @@ export function TableGrid({
16981699
)
16991700
}
17001701

1702+
function handleCopyRowId() {
1703+
const rowId = contextMenu.row?.id
1704+
if (!rowId) return
1705+
void navigator.clipboard.writeText(rowId).catch(() => {})
1706+
}
1707+
1708+
const handleGoToReferenceTable = useCallback(
1709+
(referenceTableId: string) => {
1710+
router.push(`/workspace/${workspaceId}/tables/${referenceTableId}`)
1711+
},
1712+
[router, workspaceId]
1713+
)
1714+
17011715
const handleAppendRow = useCallback(async () => {
17021716
if (isAppendingRowRef.current) return
17031717
isAppendingRowRef.current = true
@@ -4814,6 +4828,7 @@ export function TableGrid({
48144828
workflowGroups={tableWorkflowGroups}
48154829
sourceInfo={columnSourceInfo.get(column.key)}
48164830
onOpenConfig={handleConfigureColumn}
4831+
onGoToReferenceTable={handleGoToReferenceTable}
48174832
onViewWorkflow={handleViewWorkflow}
48184833
onSortColumn={onSortColumn}
48194834
onClearSort={onClearSort}
@@ -4987,6 +5002,7 @@ export function TableGrid({
49875002
onInsertAbove={handleInsertRowAbove}
49885003
onInsertBelow={handleInsertRowBelow}
49895004
onDuplicate={handleDuplicateRow}
5005+
onCopyRowId={contextMenu.row ? handleCopyRowId : undefined}
49905006
onViewExecution={handleViewExecution}
49915007
canViewExecution={
49925008
(Boolean(contextMenuExecutionId) && contextMenuHasStartedRun) ||

0 commit comments

Comments
 (0)