Skip to content

Commit e3ec480

Browse files
committed
improvement(tables): warn about referenced tables instead of blocking deletes
1 parent 049d799 commit e3ec480

29 files changed

Lines changed: 506 additions & 2151 deletions

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,14 @@ export function resolveCellRender({
151151
// (keyed on kind alone) no longer has. Renders as plain text — a currency
152152
// cell is a number cell with a symbol, so it stays left-aligned like one.
153153
if (column.type === 'currency') {
154-
return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) }
154+
return { kind: 'text', text: typeDefinition.formatForDisplay(value, column) }
155155
}
156156
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
157-
const definition = columnTypeOf(column)
158-
if (definition.editor === 'date') {
157+
if (typeDefinition.editor === 'date') {
159158
if (timezoneStatus !== undefined && timezoneStatus !== 'ready') {
160159
return { kind: 'date', text: stringifyValue(value), raw: true }
161160
}
162-
return { kind: 'date', text: definition.formatForInput(value, column) }
161+
return { kind: 'date', text: typeDefinition.formatForInput(value, column) }
163162
}
164163
if (column.type === 'string') {
165164
const text = stringifyValue(value)

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {
5050
let container: HTMLDivElement
5151
let root: Root
5252
let previewTable: ReturnType<typeof createTableDefinition> | undefined
53-
let previewStatus: 'loading' | 'error' | 'ready'
53+
let previewStatus: 'loading' | 'error' | 'missing' | 'ready'
5454
const REFERENCE_TABLE_NAMES = new Map([
5555
['table-accounts', 'Accounts'],
5656
['table-owners', 'Owners'],
@@ -403,6 +403,19 @@ describe('ReferenceRowPreview', () => {
403403
expect(container.querySelector('[data-testid="reference-preview-loader"]')).toBeNull()
404404
})
405405

406+
it('shows a not-found state when the referenced table no longer exists', () => {
407+
previewStatus = 'missing'
408+
previewTable = undefined
409+
previewQuery.data = undefined
410+
411+
renderPreview()
412+
413+
expect(container.textContent).toContain('Table not found')
414+
expect(container.textContent).not.toContain("Couldn't load reference")
415+
expect(container.querySelector('[role="table"]')).toBeNull()
416+
expect(container.querySelector('a[aria-label="Go to table"]')).toBeNull()
417+
})
418+
406419
it('shows an empty-schema state when the referenced table has no columns', () => {
407420
if (!previewTable) throw new Error('Expected the referenced table fixture')
408421
previewTable.schema.columns = []

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ interface ReferenceRowPreviewBaseProps {
3333

3434
type ReferenceRowPreviewProps = ReferenceRowPreviewBaseProps &
3535
(
36-
| { status: 'loading' | 'error' }
36+
| { status: 'loading' | 'error' | 'missing' }
3737
| {
3838
status: 'ready'
3939
table: TableDefinition
@@ -207,9 +207,9 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview(
207207
<div className='flex h-full items-center justify-center'>
208208
<Loader animate className='size-[14px] text-[var(--text-muted)]' />
209209
</div>
210-
) : status === 'error' ? (
210+
) : status === 'error' || status === 'missing' ? (
211211
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-small'>
212-
Couldn&apos;t load reference
212+
{status === 'missing' ? 'Table not found' : "Couldn't load reference"}
213213
</div>
214214
) : (
215215
<>

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -970,11 +970,13 @@ export function TableGrid({
970970
? ({ status: 'error' } as const)
971971
: referencePreviewQuery.isFetching || !referencePreviewQuery.data
972972
? ({ status: 'loading' } as const)
973-
: ({
974-
status: 'ready',
975-
table: referencePreviewQuery.data.table,
976-
row: referencePreviewQuery.data.row,
977-
} as const)
973+
: referencePreviewQuery.data.table === null
974+
? ({ status: 'missing' } as const)
975+
: ({
976+
status: 'ready',
977+
table: referencePreviewQuery.data.table,
978+
row: referencePreviewQuery.data.row,
979+
} as const)
978980
const expandedSourceRowId = activeReferenceTarget?.sourceRowId ?? null
979981

980982
const rowVirtualizer = useVirtualizer({

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
5353
import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog'
5454
import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu'
55+
import { useReferencedByWarning } from '@/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning'
5556
import { useWorkspaceTablesRoom } from '@/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room'
5657
import { useLogByExecutionId } from '@/hooks/queries/logs'
5758
import {
@@ -1462,6 +1463,8 @@ export function Table({
14621463
: 0
14631464

14641465
const deleteTableMutation = useDeleteTable(workspaceId)
1466+
const pendingDeleteTableIds = showDeleteTableConfirm ? [tableId] : []
1467+
const referencedByWarning = useReferencedByWarning(workspaceId, pendingDeleteTableIds)
14651468
const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId })
14661469
const exportTableAsync = useExportTable({ workspaceId, tableId })
14671470
const handleDeleteTable = async () => {
@@ -1892,6 +1895,7 @@ export function Table({
18921895
{ text: tableData?.name ?? 'this table', bold: true },
18931896
'? ',
18941897
{ text: `All ${tableData?.rowCount ?? 0} rows will be removed.`, error: true },
1898+
...referencedByWarning,
18951899
' You can restore it from Recently Deleted in Settings.',
18961900
]}
18971901
confirm={{
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/hooks/queries/tables', () => ({
7+
useTablesList: vi.fn(),
8+
}))
9+
10+
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
11+
useOptionalWorkspaceHostContext: vi.fn(),
12+
}))
13+
14+
import { referencedByWarningText } from '@/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning'
15+
16+
describe('referencedByWarningText', () => {
17+
it('adds nothing when no surviving table references the deletion', () => {
18+
expect(referencedByWarningText([])).toEqual([])
19+
})
20+
21+
it('names a single referencing table', () => {
22+
expect(referencedByWarningText(['Orders'])).toEqual([
23+
' Referenced by Orders. Those references will show as not found.',
24+
])
25+
})
26+
27+
it('joins referencing tables as a readable list', () => {
28+
expect(referencedByWarningText(['Invoices', 'Orders'])).toEqual([
29+
' Referenced by Invoices and Orders. Those references will show as not found.',
30+
])
31+
})
32+
33+
it('lists the first three names and summarizes the rest', () => {
34+
expect(referencedByWarningText(['Accounts', 'Invoices', 'Leads', 'Orders', 'Quotes'])).toEqual([
35+
' Referenced by Accounts, Invoices, Leads, and 2 more. Those references will show as not found.',
36+
])
37+
})
38+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
3+
import type { ChipConfirmTextSegment } from '@sim/emcn'
4+
import { findReferencingTables } from '@/lib/table/reference-columns/referrers'
5+
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
6+
import { useTablesList } from '@/hooks/queries/tables'
7+
8+
const MAX_LISTED_REFERRERS = 3
9+
const NAME_LIST_FORMAT = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' })
10+
const NO_SEGMENTS: readonly ChipConfirmTextSegment[] = []
11+
12+
/**
13+
* Confirmation copy naming the surviving tables that reference a pending deletion. Deletion is
14+
* not blocked; the copy warns that those references will stop resolving.
15+
*/
16+
export function referencedByWarningText(
17+
referrerNames: readonly string[]
18+
): readonly ChipConfirmTextSegment[] {
19+
if (referrerNames.length === 0) return NO_SEGMENTS
20+
21+
const listed = referrerNames.slice(0, MAX_LISTED_REFERRERS)
22+
const remaining = referrerNames.length - listed.length
23+
const names = remaining > 0 ? [...listed, `${remaining} more`] : listed
24+
25+
return [
26+
` Referenced by ${NAME_LIST_FORMAT.format(names)}. Those references will show as not found.`,
27+
]
28+
}
29+
30+
/**
31+
* Warning copy for the tables a delete confirmation would archive. Empty while Reference
32+
* columns are disabled, nothing is pending, or no surviving table references the deletion.
33+
*/
34+
export function useReferencedByWarning(
35+
workspaceId: string,
36+
deletedTableIds: readonly string[]
37+
): readonly ChipConfirmTextSegment[] {
38+
const hostContext = useOptionalWorkspaceHostContext()
39+
const enabled = (hostContext?.features?.referenceColumns ?? false) && deletedTableIds.length > 0
40+
const { data: tables } = useTablesList(workspaceId, 'active', { enabled })
41+
42+
if (!enabled || !tables) return NO_SEGMENTS
43+
const referrers = findReferencingTables(tables, new Set(deletedTableIds))
44+
return referencedByWarningText(referrers.map((table) => table.name))
45+
}

‎apps/sim/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,9 @@ import { folderKeys } from '@/hooks/queries/utils/folder-keys'
77
import { tableKeys } from '@/hooks/queries/utils/table-keys'
88

99
/**
10-
* Keeps the tables browser live: joins the workspace-tables room so a `workspace-tables-changed`
11-
* broadcast (fanned out by the table + table-folder mutation services) invalidates table lists,
12-
* names, reference previews, AND table folders so every viewer refetches without waiting for
13-
* staleness. A created/renamed/
14-
* moved/deleted/restored table changes the list result (including folder placement); a folder
15-
* create/rename/delete/restore changes the folder tree — the page renders both, so both are
16-
* invalidated. Thin binding over {@link useWorkspaceInvalidationRoom}.
10+
* Table and table-folder mutations share this room because the browser renders both the table
11+
* list and folder tree. Broadcast invalidation keeps every viewer current without waiting for
12+
* query staleness.
1713
*/
1814
export function useWorkspaceTablesRoom(workspaceId: string): void {
1915
const queryClient = useQueryClient()

‎apps/sim/app/workspace/[workspaceId]/tables/tables.tsx‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
TablesListContextMenu,
7272
} from '@/app/workspace/[workspaceId]/tables/components'
7373
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
74+
import { useReferencedByWarning } from '@/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning'
7475
import { useWorkspaceTablesRoom } from '@/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room'
7576
import TablesLoading from '@/app/workspace/[workspaceId]/tables/loading'
7677
import {
@@ -127,6 +128,21 @@ const ROOT_LABEL = FOLDERED_RESOURCE_HEADERS.table.rootLabel
127128

128129
const EMPTY_TABLES: TableDefinition[] = []
129130

131+
/** Tables inside `folderIds` or any folder nested beneath them. */
132+
function tableIdsInFolderSubtrees(
133+
tables: readonly TableDefinition[],
134+
folderIds: readonly string[],
135+
descendantFolderIds: ReadonlyMap<string, ReadonlySet<string>>
136+
): string[] {
137+
if (folderIds.length === 0) return []
138+
const coveredFolderIds = new Set(
139+
folderIds.flatMap((folderId) => [folderId, ...(descendantFolderIds.get(folderId) ?? [])])
140+
)
141+
return tables.flatMap((table) =>
142+
table.folderId && coveredFolderIds.has(table.folderId) ? [table.id] : []
143+
)
144+
}
145+
130146
/** A list row (and the right-clicked row), resolved to the entity it refers to. */
131147
type TableResourceItem =
132148
| { kind: 'table'; table: TableDefinition }
@@ -595,6 +611,23 @@ export function Tables() {
595611
return selectionLabel(count, firstName)
596612
}, [selectedTableIds, selectedFolderIds, tables, folderById])
597613

614+
const deleteFolderIds =
615+
isDeleteFolderDialogOpen && activeFolder
616+
? [activeFolder.id]
617+
: isBulkDeleteDialogOpen
618+
? selectedFolderIds
619+
: []
620+
/** Tables the open delete confirmation would archive, including every table inside a folder. */
621+
const pendingDeleteTableIds = isDeleteDialogOpen
622+
? activeTable
623+
? [activeTable.id]
624+
: []
625+
: [
626+
...(isBulkDeleteDialogOpen ? selectedTableIds : []),
627+
...tableIdsInFolderSubtrees(tables, deleteFolderIds, descendantFolderIds),
628+
]
629+
const referencedByWarning = useReferencedByWarning(workspaceId, pendingDeleteTableIds)
630+
598631
const currentFolderActions: DropdownOption[] | undefined = useMemo(() => {
599632
if (!currentFolderId) return undefined
600633
const folder = folderById.get(currentFolderId)
@@ -1480,6 +1513,7 @@ export function Tables() {
14801513
{ text: activeTable?.name ?? 'this table', bold: true },
14811514
'? ',
14821515
{ text: `All ${activeTable?.rowCount ?? 0} rows will be removed.`, error: true },
1516+
...referencedByWarning,
14831517
' You can restore it from Recently Deleted in Settings.',
14841518
]}
14851519
confirm={{
@@ -1503,6 +1537,7 @@ export function Tables() {
15031537
{ text: activeFolder?.name ?? 'this folder', bold: true },
15041538
'? ',
15051539
{ text: 'Every table and subfolder inside it will be deleted too.', error: true },
1540+
...referencedByWarning,
15061541
' You can restore those tables from Recently Deleted in Settings.',
15071542
]}
15081543
confirm={{
@@ -1529,6 +1564,7 @@ export function Tables() {
15291564
: 'All of their rows will be removed.',
15301565
error: true,
15311566
},
1567+
...referencedByWarning,
15321568
' You can restore those tables from Recently Deleted in Settings.',
15331569
]}
15341570
confirm={{

‎apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts‎

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,7 +1546,7 @@ describe('copyForkResourceContainers table views', () => {
15461546
expect(insert).not.toHaveBeenCalled()
15471547
})
15481548

1549-
it('rejects an unavailable referenced-table dependency before inserting copies', async () => {
1549+
it('copies a table whose referenced table was deleted and keeps the original target', async () => {
15501550
const now = new Date('2026-08-19T00:00:00.000Z')
15511551
const selectedDefinition = {
15521552
id: 'table-orders',
@@ -1577,37 +1577,53 @@ describe('copyForkResourceContainers table views', () => {
15771577
createdAt: now,
15781578
updatedAt: now,
15791579
}
1580-
const insert = vi.fn()
1580+
const inserted = new Map<unknown, Array<Record<string, unknown>>>()
15811581
let definitionRead = 0
15821582
const tx = {
15831583
select: () => ({
1584-
from: () => ({
1585-
where: () => Promise.resolve(definitionRead++ === 0 ? [selectedDefinition] : []),
1584+
from: (table: unknown) => ({
1585+
where: () => {
1586+
if (table !== userTableDefinitions) return Promise.resolve([])
1587+
return Promise.resolve(definitionRead++ === 0 ? [selectedDefinition] : [])
1588+
},
15861589
}),
15871590
}),
1588-
insert,
1591+
insert: (table: unknown) => ({
1592+
values: (values: Array<Record<string, unknown>>) => {
1593+
inserted.set(table, values)
1594+
return Promise.resolve()
1595+
},
1596+
}),
15891597
}
15901598

1591-
await expect(
1592-
copyForkResourceContainers({
1593-
tx: tx as unknown as DbOrTx,
1594-
sourceWorkspaceId: 'src-ws',
1595-
childWorkspaceId: 'child-ws',
1596-
userId: 'user-1',
1597-
now,
1598-
selection: {
1599-
customTools: [],
1600-
skills: [],
1601-
mcpServers: [],
1602-
workflowMcpServers: [],
1603-
tables: ['table-orders'],
1604-
knowledgeBases: [],
1605-
},
1606-
workflowIdMap: new Map(),
1607-
documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
1608-
})
1609-
).rejects.toThrow('Referenced table table-accounts is unavailable for copy')
1610-
expect(insert).not.toHaveBeenCalled()
1599+
const result = await copyForkResourceContainers({
1600+
tx: tx as unknown as DbOrTx,
1601+
sourceWorkspaceId: 'src-ws',
1602+
childWorkspaceId: 'child-ws',
1603+
userId: 'user-1',
1604+
now,
1605+
selection: {
1606+
customTools: [],
1607+
skills: [],
1608+
mcpServers: [],
1609+
workflowMcpServers: [],
1610+
tables: ['table-orders'],
1611+
knowledgeBases: [],
1612+
},
1613+
workflowIdMap: new Map(),
1614+
documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
1615+
})
1616+
1617+
const tableMap = result.idMap.get('table')
1618+
expect(tableMap?.size).toBe(1)
1619+
expect(result.contentPlan.tables).toEqual([
1620+
{ sourceId: 'table-orders', childId: tableMap?.get('table-orders') },
1621+
])
1622+
const copiedDefinitions = inserted.get(userTableDefinitions)
1623+
expect(copiedDefinitions).toHaveLength(1)
1624+
expect(copiedDefinitions?.[0]?.schema).toMatchObject({
1625+
columns: [{ referenceTableId: 'table-accounts' }],
1626+
})
16111627
})
16121628

16131629
it('bounds the expanded referenced-table dependency set', async () => {

0 commit comments

Comments
 (0)