Skip to content

Commit dd1a827

Browse files
committed
improvement(tables): warn about referenced tables instead of blocking deletes
1 parent 803b9a8 commit dd1a827

29 files changed

Lines changed: 509 additions & 2151 deletions

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,17 @@ export function resolveCellRender({
154154
// (keyed on kind alone) no longer has. Renders as plain text — a currency
155155
// cell is a number cell with a symbol, so it stays left-aligned like one.
156156
if (column.type === 'currency') {
157-
return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) }
157+
return { kind: 'text', text: typeDefinition.formatForDisplay(value, column) }
158158
}
159159
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
160-
const definition = columnTypeOf(column)
161-
if (definition.editor === 'date') {
160+
if (typeDefinition.editor === 'date') {
162161
if (timezoneStatus !== undefined && timezoneStatus !== 'ready') {
163162
return { kind: 'date', text: stringifyValue(value), raw: true }
164163
}
165-
return { kind: 'date', text: definition.formatForInput(value, column, { timezone: timeZone }) }
164+
return {
165+
kind: 'date',
166+
text: typeDefinition.formatForInput(value, column, { timezone: timeZone }),
167+
}
166168
}
167169
if (column.type === 'string') {
168170
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'],
@@ -404,6 +404,19 @@ describe('ReferenceRowPreview', () => {
404404
expect(container.querySelector('[data-testid="reference-preview-loader"]')).toBeNull()
405405
})
406406

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

3535
type ReferenceRowPreviewProps = ReferenceRowPreviewBaseProps &
3636
(
37-
| { status: 'loading' | 'error' }
37+
| { status: 'loading' | 'error' | 'missing' }
3838
| {
3939
status: 'ready'
4040
table: TableDefinition
@@ -210,9 +210,9 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview(
210210
<div className='flex h-full items-center justify-center'>
211211
<Loader animate className='size-[14px] text-[var(--text-muted)]' />
212212
</div>
213-
) : status === 'error' ? (
213+
) : status === 'error' || status === 'missing' ? (
214214
<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-small'>
215-
Couldn&apos;t load reference
215+
{status === 'missing' ? 'Table not found' : "Couldn't load reference"}
216216
</div>
217217
) : (
218218
<>

‎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
@@ -955,11 +955,13 @@ export function TableGrid({
955955
? ({ status: 'error' } as const)
956956
: referencePreviewQuery.isFetching || !referencePreviewQuery.data
957957
? ({ status: 'loading' } as const)
958-
: ({
959-
status: 'ready',
960-
table: referencePreviewQuery.data.table,
961-
row: referencePreviewQuery.data.row,
962-
} as const)
958+
: referencePreviewQuery.data.table === null
959+
? ({ status: 'missing' } as const)
960+
: ({
961+
status: 'ready',
962+
table: referencePreviewQuery.data.table,
963+
row: referencePreviewQuery.data.row,
964+
} as const)
963965
const expandedSourceRowId = activeReferenceTarget?.sourceRowId ?? null
964966

965967
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
@@ -51,6 +51,7 @@ import {
5151
} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
5252
import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog'
5353
import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu'
54+
import { useReferencedByWarning } from '@/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning'
5455
import { useWorkspaceTablesRoom } from '@/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room'
5556
import { useLogByExecutionId } from '@/hooks/queries/logs'
5657
import {
@@ -1402,6 +1403,8 @@ export function Table({
14021403
: 0
14031404

14041405
const deleteTableMutation = useDeleteTable(workspaceId)
1406+
const pendingDeleteTableIds = showDeleteTableConfirm ? [tableId] : []
1407+
const referencedByWarning = useReferencedByWarning(workspaceId, pendingDeleteTableIds)
14051408
const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId })
14061409
const exportTableAsync = useExportTable({ workspaceId, tableId })
14071410
const handleDeleteTable = async () => {
@@ -1815,6 +1818,7 @@ export function Table({
18151818
{ text: tableData?.name ?? 'this table', bold: true },
18161819
'? ',
18171820
{ text: `All ${tableData?.rowCount ?? 0} rows will be removed.`, error: true },
1821+
...referencedByWarning,
18181822
' You can restore it from Recently Deleted in Settings.',
18191823
]}
18201824
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
@@ -1378,7 +1378,7 @@ describe('copyForkResourceContainers table views', () => {
13781378
expect(insert).not.toHaveBeenCalled()
13791379
})
13801380

1381-
it('rejects an unavailable referenced-table dependency before inserting copies', async () => {
1381+
it('copies a table whose referenced table was deleted and keeps the original target', async () => {
13821382
const now = new Date('2026-08-19T00:00:00.000Z')
13831383
const selectedDefinition = {
13841384
id: 'table-orders',
@@ -1409,37 +1409,53 @@ describe('copyForkResourceContainers table views', () => {
14091409
createdAt: now,
14101410
updatedAt: now,
14111411
}
1412-
const insert = vi.fn()
1412+
const inserted = new Map<unknown, Array<Record<string, unknown>>>()
14131413
let definitionRead = 0
14141414
const tx = {
14151415
select: () => ({
1416-
from: () => ({
1417-
where: () => Promise.resolve(definitionRead++ === 0 ? [selectedDefinition] : []),
1416+
from: (table: unknown) => ({
1417+
where: () => {
1418+
if (table !== userTableDefinitions) return Promise.resolve([])
1419+
return Promise.resolve(definitionRead++ === 0 ? [selectedDefinition] : [])
1420+
},
14181421
}),
14191422
}),
1420-
insert,
1423+
insert: (table: unknown) => ({
1424+
values: (values: Array<Record<string, unknown>>) => {
1425+
inserted.set(table, values)
1426+
return Promise.resolve()
1427+
},
1428+
}),
14211429
}
14221430

1423-
await expect(
1424-
copyForkResourceContainers({
1425-
tx: tx as unknown as DbOrTx,
1426-
sourceWorkspaceId: 'src-ws',
1427-
childWorkspaceId: 'child-ws',
1428-
userId: 'user-1',
1429-
now,
1430-
selection: {
1431-
customTools: [],
1432-
skills: [],
1433-
mcpServers: [],
1434-
workflowMcpServers: [],
1435-
tables: ['table-orders'],
1436-
knowledgeBases: [],
1437-
},
1438-
workflowIdMap: new Map(),
1439-
documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
1440-
})
1441-
).rejects.toThrow('Referenced table table-accounts is unavailable for copy')
1442-
expect(insert).not.toHaveBeenCalled()
1431+
const result = await copyForkResourceContainers({
1432+
tx: tx as unknown as DbOrTx,
1433+
sourceWorkspaceId: 'src-ws',
1434+
childWorkspaceId: 'child-ws',
1435+
userId: 'user-1',
1436+
now,
1437+
selection: {
1438+
customTools: [],
1439+
skills: [],
1440+
mcpServers: [],
1441+
workflowMcpServers: [],
1442+
tables: ['table-orders'],
1443+
knowledgeBases: [],
1444+
},
1445+
workflowIdMap: new Map(),
1446+
documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
1447+
})
1448+
1449+
const tableMap = result.idMap.get('table')
1450+
expect(tableMap?.size).toBe(1)
1451+
expect(result.contentPlan.tables).toEqual([
1452+
{ sourceId: 'table-orders', childId: tableMap?.get('table-orders') },
1453+
])
1454+
const copiedDefinitions = inserted.get(userTableDefinitions)
1455+
expect(copiedDefinitions).toHaveLength(1)
1456+
expect(copiedDefinitions?.[0]?.schema).toMatchObject({
1457+
columns: [{ referenceTableId: 'table-accounts' }],
1458+
})
14431459
})
14441460

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

0 commit comments

Comments
 (0)