Skip to content

Commit 021bb41

Browse files
committed
feat(tables): add row ID copy and reference navigation
1 parent 3d133ab commit 021bb41

6 files changed

Lines changed: 279 additions & 1 deletion

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
@@ -94,6 +96,7 @@ export function ContextMenu({
9496
onInsertAbove,
9597
onInsertBelow,
9698
onDuplicate,
99+
onCopyRowId,
97100
onViewExecution,
98101
canViewExecution = false,
99102
canEditCell = true,
@@ -252,6 +255,12 @@ export function ContextMenu({
252255
<Duplicate />
253256
Duplicate row
254257
</DropdownMenuItem>
258+
{onCopyRowId && (
259+
<DropdownMenuItem onSelect={onCopyRowId}>
260+
<Duplicate />
261+
Copy Row Id
262+
</DropdownMenuItem>
263+
)}
255264
<DropdownMenuSeparator />
256265
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
257266
<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
@@ -28,6 +28,8 @@ interface ColumnHeaderMenuProps {
2828
onColumnSelect: (colIndex: number, shiftKey: boolean) => void
2929
onInsertLeft: (columnName: string) => void
3030
onInsertRight: (columnName: string) => void
31+
/** Opens the table targeted by a Reference column. */
32+
onGoToReferenceTable?: (tableId: string) => void
3133
onDeleteColumn: (columnName: string) => void
3234
onResizeStart: (columnKey: string) => void
3335
onResize: (columnKey: string, width: number) => void
@@ -80,6 +82,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
8082
onColumnSelect,
8183
onInsertLeft,
8284
onInsertRight,
85+
onGoToReferenceTable,
8386
onDeleteColumn,
8487
onResizeStart,
8588
onResize,
@@ -354,6 +357,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
354357
onOpenConfig={onOpenConfig}
355358
schemaLockedReason={schemaLockedReason}
356359
deleteLockedReason={deleteLockedReason}
360+
onGoToReferenceTable={onGoToReferenceTable}
357361
onInsertLeft={onInsertLeft}
358362
onInsertRight={onInsertRight}
359363
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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
Pin,
2626
PinOff,
2727
PlayOutline,
28+
SquareArrowUpRight,
2829
Trash,
2930
Workflow,
3031
X,
@@ -75,6 +76,8 @@ interface ColumnOptionsMenuProps {
7576
schemaLockedReason?: string
7677
/** Why deleting is unavailable; disables the destructive column row. */
7778
deleteLockedReason?: string
79+
/** Opens the table targeted by a Reference column. */
80+
onGoToReferenceTable?: (tableId: string) => void
7881
onInsertLeft: (columnName: string) => void
7982
onInsertRight: (columnName: string) => void
8083
onDeleteColumn: (columnName: string) => void
@@ -147,6 +150,7 @@ export function ColumnOptionsMenu({
147150
onOpenConfig,
148151
schemaLockedReason,
149152
deleteLockedReason,
153+
onGoToReferenceTable,
150154
onInsertLeft,
151155
onInsertRight,
152156
onDeleteColumn,
@@ -170,6 +174,7 @@ export function ColumnOptionsMenu({
170174
const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete)
171175
const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0
172176
const runLabels = runMenuLabels(hasActiveFilter)
177+
const referenceTableId = column.type === 'reference' ? column.referenceTableId : undefined
173178
return (
174179
<DropdownMenu open={open} onOpenChange={onOpenChange}>
175180
<DropdownMenuTrigger asChild>
@@ -256,6 +261,12 @@ export function ColumnOptionsMenu({
256261
View workflow
257262
</DropdownMenuItem>
258263
)}
264+
{referenceTableId && onGoToReferenceTable && (
265+
<DropdownMenuItem onSelect={() => onGoToReferenceTable(referenceTableId)}>
266+
<SquareArrowUpRight />
267+
Go to Reference Table
268+
</DropdownMenuItem>
269+
)}
259270
<MenuRow reason={schemaLockedReason}>
260271
<DropdownMenuItem
261272
disabled={Boolean(schemaLockedReason)}

‎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
@@ -9,7 +9,7 @@ import type { TableCellSelection } from '@sim/realtime-protocol/table-presence'
99
import { getErrorMessage } from '@sim/utils/errors'
1010
import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste'
1111
import { useVirtualizer } from '@tanstack/react-virtual'
12-
import { useParams } from 'next/navigation'
12+
import { useParams, useRouter } from 'next/navigation'
1313
import { usePostHog } from 'posthog-js/react'
1414
import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables'
1515
import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard'
@@ -492,6 +492,7 @@ export function TableGrid({
492492
const params = useParams()
493493
const workspaceId = propWorkspaceId || (params.workspaceId as string)
494494
const tableId = propTableId || (params.tableId as string)
495+
const router = useRouter()
495496
const workspaceIdRef = useRef(workspaceId)
496497
workspaceIdRef.current = workspaceId
497498
const tableIdRef = useRef(tableId)
@@ -1739,6 +1740,19 @@ export function TableGrid({
17391740
)
17401741
}
17411742

1743+
function handleCopyRowId() {
1744+
const rowId = contextMenu.row?.id
1745+
if (!rowId) return
1746+
void navigator.clipboard.writeText(rowId).catch(() => {})
1747+
}
1748+
1749+
const handleGoToReferenceTable = useCallback(
1750+
(referenceTableId: string) => {
1751+
router.push(`/workspace/${workspaceId}/tables/${referenceTableId}`)
1752+
},
1753+
[router, workspaceId]
1754+
)
1755+
17421756
const handleAppendRow = useCallback(async () => {
17431757
if (isAppendingRowRef.current) return
17441758
isAppendingRowRef.current = true
@@ -4949,6 +4963,7 @@ export function TableGrid({
49494963
deleteLockedReason={
49504964
locks?.deleteLocked ? LOCK_TOOLTIPS.delete : undefined
49514965
}
4966+
onGoToReferenceTable={handleGoToReferenceTable}
49524967
onViewWorkflow={handleViewWorkflow}
49534968
onSortColumn={onSortColumn}
49544969
onClearSort={onClearSort}
@@ -5128,6 +5143,7 @@ export function TableGrid({
51285143
onInsertAbove={handleInsertRowAbove}
51295144
onInsertBelow={handleInsertRowBelow}
51305145
onDuplicate={handleDuplicateRow}
5146+
onCopyRowId={contextMenu.row ? handleCopyRowId : undefined}
51315147
onViewExecution={handleViewExecution}
51325148
canViewExecution={
51335149
(Boolean(contextMenuExecutionId) && contextMenuHasStartedRun) ||

0 commit comments

Comments
 (0)