Skip to content

Commit 6ece736

Browse files
committed
fix(tables): preserve column actions across undo
1 parent 7d8e4c0 commit 6ece736

11 files changed

Lines changed: 217 additions & 60 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { toError } from '@sim/utils/errors'
77
import { useIsMutating } from '@tanstack/react-query'
88
import { isValidationError } from '@/lib/api/client/errors'
99
import type { ColumnDefinition, SelectOption } from '@/lib/table'
10+
import { getColumnId } from '@/lib/table/column-keys'
1011
import { getCurrencyOptions, resolveCurrencyCode } from '@/lib/table/currency'
1112
import {
1213
FieldError,
@@ -63,6 +64,11 @@ interface ColumnConfigSidebarProps {
6364
* or the create failed, and the owner has already surfaced why.
6465
*/
6566
onDraftSave: (options: SelectOption[], multiple: boolean) => Promise<boolean>
67+
onColumnTypeChange: (
68+
columnName: string,
69+
previousColumn: ColumnDefinition,
70+
newColumn: ColumnDefinition
71+
) => void
6672
/** Existing column record for modes that carry a `columnName`; otherwise null. */
6773
existingColumn: ColumnDefinition | null
6874
workspaceId: string
@@ -108,15 +114,14 @@ function ColumnConfigBody({
108114
config,
109115
onClose,
110116
onDraftSave,
117+
onColumnTypeChange,
111118
existingColumn,
112119
workspaceId,
113120
tableId,
114121
}: ColumnConfigBodyProps) {
115122
const updateColumn = useUpdateColumn({ workspaceId, tableId })
116123
const draftSaving = useIsMutating({ mutationKey: tableKeys.columnWrites(tableId) }) > 0
117124

118-
// draft-select and convert-select always edit an option set; configure
119-
// shows whichever configuration the column's type carries.
120125
const isSelectTarget = config.mode !== 'configure' || existingColumn?.type === 'select'
121126
const isCurrencyTarget = config.mode === 'configure' && existingColumn?.type === 'currency'
122127

@@ -148,7 +153,6 @@ function ColumnConfigBody({
148153
setOptionsError(optionsIssue)
149154
return
150155
}
151-
152156
if (config.mode === 'draft-select') {
153157
// The draft's owner persists name + options together and reports back;
154158
// the parent closes the sidebar on success, so nothing to do here.
@@ -160,7 +164,7 @@ function ColumnConfigBody({
160164

161165
try {
162166
if (config.mode === 'convert-select') {
163-
await updateColumn.mutateAsync({
167+
const result = await updateColumn.mutateAsync({
164168
columnName: config.columnName,
165169
updates: {
166170
type: 'select',
@@ -172,6 +176,18 @@ function ColumnConfigBody({
172176
...(existingColumn?.unique ? { unique: false } : {}),
173177
},
174178
})
179+
if (existingColumn) {
180+
const updatedColumn = result.data.columns.find(
181+
(candidate) => getColumnId(candidate) === config.columnName
182+
) ?? {
183+
...existingColumn,
184+
type: 'select',
185+
options: trimmedOptions,
186+
unique: false,
187+
...(multipleInput ? { multiple: true } : {}),
188+
}
189+
onColumnTypeChange(config.columnName, existingColumn, updatedColumn)
190+
}
175191
toast.success(`Saved "${columnLabel}"`)
176192
onClose()
177193
return
@@ -220,6 +236,7 @@ function ColumnConfigBody({
220236
variant='ghost'
221237
size='sm'
222238
onClick={onClose}
239+
disabled={saveDisabled}
223240
className='!p-1 size-7'
224241
aria-label='Close'
225242
>
@@ -270,7 +287,7 @@ function ColumnConfigBody({
270287
</div>
271288

272289
<div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'>
273-
<Button variant='default' size='sm' onClick={onClose}>
290+
<Button variant='default' size='sm' onClick={onClose} disabled={saveDisabled}>
274291
Cancel
275292
</Button>
276293
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ interface ColumnHeaderMenuProps {
1919
isRenaming: boolean
2020
isColumnSelected: boolean
2121
renameValue: string
22-
/** True after a refused rename — paints the name red until it's edited. */
2322
renameError?: boolean
2423
onRenameValueChange: (value: string) => void
2524
onRenameSubmit: () => void

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,15 @@
33
import React, { useEffect, useRef } from 'react'
44
import { cn } from '@sim/emcn'
55
import type { ColumnDefinition } from '@/lib/table'
6-
import { ColumnTypeIcon } from './column-type-icon'
6+
import { ColumnTypeIcon } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon'
77

88
interface DraftColumnHeaderProps {
99
type: ColumnDefinition['type']
1010
name: string
1111
/** True after a refused commit — paints the name red until it's edited. */
1212
invalid: boolean
1313
onNameChange: (name: string) => void
14-
/** Enter or blur. The grid decides whether this persists the column. */
1514
onCommit: () => void
16-
/** Escape. Discards the draft — nothing was ever persisted. */
1715
onCancel: () => void
1816
}
1917

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ interface ColumnOptionsMenuProps {
7777
* config surface behind it (the workflow sidebar); plain columns edit
7878
* name/type/unique from this menu directly and omit it. */
7979
onOpenConfig?: (columnName: string) => void
80-
/** Starts the inline header rename. Plain and enrichment columns only. */
8180
onRenameColumn?: (columnName: string) => void
8281
/** Converts the column to `type`. Callers route `select` conversions through
8382
* the config sidebar (the option set must be collected first). */

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

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,14 @@ interface TableGridProps {
309309
draftSelectSaveSinkRef: React.MutableRefObject<
310310
((options: SelectOption[], multiple: boolean) => Promise<boolean>) | null
311311
>
312+
/**
313+
* Ref the grid populates with the sidebar's successful type-conversion undo
314+
* recorder, keeping the undo stack owned by the grid.
315+
*/
316+
recordColumnTypeChangeSinkRef: React.MutableRefObject<
317+
| ((columnName: string, previousColumn: ColumnDefinition, newColumn: ColumnDefinition) => void)
318+
| null
319+
>
312320
/**
313321
* Ref the grid populates with its draft discard. The wrapper fires it from
314322
* every slideout transition other than entering `draft-select`, so
@@ -501,6 +509,7 @@ export function TableGrid({
501509
columnRenameSinkRef,
502510
addColumnOfTypeSinkRef,
503511
draftSelectSaveSinkRef,
512+
recordColumnTypeChangeSinkRef,
504513
abortColumnDraftSinkRef,
505514
layoutSnapshotSinkRef,
506515
afterDeleteRowsSinkRef,
@@ -1540,7 +1549,6 @@ export function TableGrid({
15401549
const handleFindCloseRef = useRef(handleFindClose)
15411550
handleFindCloseRef.current = handleFindClose
15421551

1543-
/** True after a refused rename — paints the header input red until it's edited. */
15441552
const [renameError, setRenameError] = useState(false)
15451553

15461554
const columnRename = useInlineRename({
@@ -3946,9 +3954,13 @@ export function TableGrid({
39463954
{ name, type: 'string', position: index },
39473955
{
39483956
onSuccess: (result) => {
3949-
const newId = result.data.columns.find((c) => c.name === name)?.id ?? name
3957+
const createdColumn = result.data.columns.find((c) => c.name === name) ?? {
3958+
name,
3959+
type: 'string' as const,
3960+
}
3961+
const newId = getColumnId(createdColumn)
39503962
pushUndoRef.current(
3951-
{ type: 'create-column', columnName: name, columnId: newId, position: index },
3963+
{ type: 'create-column', column: createdColumn, position: index },
39523964
owner
39533965
)
39543966
// Skipped after a mid-flight view switch: the destination re-seeded
@@ -3973,11 +3985,12 @@ export function TableGrid({
39733985
{ name, type: 'string', position },
39743986
{
39753987
onSuccess: (result) => {
3976-
const newId = result.data.columns.find((c) => c.name === name)?.id ?? name
3977-
pushUndoRef.current(
3978-
{ type: 'create-column', columnName: name, columnId: newId, position },
3979-
owner
3980-
)
3988+
const createdColumn = result.data.columns.find((c) => c.name === name) ?? {
3989+
name,
3990+
type: 'string' as const,
3991+
}
3992+
const newId = getColumnId(createdColumn)
3993+
pushUndoRef.current({ type: 'create-column', column: createdColumn, position }, owner)
39813994
if (owner === viewLayoutKeyRef.current) insertColumnInOrder(columnId, newId, 'right')
39823995
},
39833996
}
@@ -4042,11 +4055,12 @@ export function TableGrid({
40424055
draftPersistingRef.current = true
40434056
try {
40444057
const result = await addColumnMutation.mutateAsync({ name, type: draft.type, ...metadata })
4045-
const newId = result.data.columns.find((c) => c.name === name)?.id ?? name
4046-
pushUndoRef.current(
4047-
{ type: 'create-column', columnName: name, columnId: newId, position },
4048-
owner
4049-
)
4058+
const createdColumn = result.data.columns.find((c) => c.name === name) ?? {
4059+
name,
4060+
type: draft.type,
4061+
...metadata,
4062+
}
4063+
pushUndoRef.current({ type: 'create-column', column: createdColumn, position }, owner)
40504064
setColumnDraft(null)
40514065
return true
40524066
} catch (err) {
@@ -4089,19 +4103,25 @@ export function TableGrid({
40894103
persistDraft({ options, ...(multiple ? { multiple: true } : {}) })
40904104
abortColumnDraftSinkRef.current = () => setColumnDraft(null)
40914105

4092-
/** Toggle the `unique` constraint on a plain column, recording the flip for undo. */
4106+
/** Toggle the `unique` constraint on a plain column, recording successful flips for undo. */
40934107
const handleToggleUnique = useCallback((columnKey: string) => {
40944108
const column = columnsRef.current.find((c) => c.key === columnKey)
40954109
if (!column) return
40964110
const previousValue = !!column.unique
4097-
pushUndoRef.current({
4098-
type: 'toggle-column-constraint',
4099-
columnName: columnKey,
4100-
constraint: 'unique',
4101-
previousValue,
4102-
newValue: !previousValue,
4103-
})
4104-
updateColumnMutation.mutate({ columnName: columnKey, updates: { unique: !previousValue } })
4111+
updateColumnMutation.mutate(
4112+
{ columnName: columnKey, updates: { unique: !previousValue } },
4113+
{
4114+
onSuccess: () => {
4115+
pushUndoRef.current({
4116+
type: 'toggle-column-constraint',
4117+
columnName: columnKey,
4118+
constraint: 'unique',
4119+
previousValue,
4120+
newValue: !previousValue,
4121+
})
4122+
},
4123+
}
4124+
)
41054125
}, [])
41064126

41074127
/** Open the workflow-config sidebar to spawn a brand-new workflow group. */
@@ -4128,7 +4148,6 @@ export function TableGrid({
41284148
[onOpenWorkflowConfig, workflowGroupById]
41294149
)
41304150

4131-
/** Starts the inline header rename from the menu's "Rename column" item. */
41324151
const handleRenameColumn = useCallback(
41334152
(columnName: string) => {
41344153
const column = columnsRef.current.find((c) => c.key === columnName)
@@ -4150,22 +4169,41 @@ export function TableGrid({
41504169
onOpenColumnConfig({ mode: 'convert-select', columnName })
41514170
return
41524171
}
4153-
const previousType = column.type
41544172
// Recorded on success only: the server refuses a retype whose existing
41554173
// values can't convert (the hook toasts why), and an entry for a change
41564174
// that never landed would make undo "restore" the type it already has.
41574175
updateColumnMutation.mutate(
41584176
{ columnName, updates: { type: newType } },
41594177
{
4160-
onSuccess: () => {
4161-
pushUndoRef.current({ type: 'update-column-type', columnName, previousType, newType })
4178+
onSuccess: (result) => {
4179+
const updatedColumn = result.data.columns.find(
4180+
(candidate) => getColumnId(candidate) === columnName
4181+
) ?? {
4182+
...column,
4183+
type: newType,
4184+
}
4185+
pushUndoRef.current({
4186+
type: 'update-column-type',
4187+
columnName,
4188+
previousColumn: column,
4189+
newColumn: updatedColumn,
4190+
})
41624191
},
41634192
}
41644193
)
41654194
},
41664195
[onOpenColumnConfig]
41674196
)
41684197

4198+
recordColumnTypeChangeSinkRef.current = (columnName, previousColumn, newColumn) => {
4199+
pushUndoRef.current({
4200+
type: 'update-column-type',
4201+
columnName,
4202+
previousColumn,
4203+
newColumn,
4204+
})
4205+
}
4206+
41694207
/** Opens the config sidebar for a select's options / a currency's code. */
41704208
const handleOpenConfigure = useCallback(
41714209
(columnName: string) => {
@@ -4192,12 +4230,6 @@ export function TableGrid({
41924230
deleteWorkflowGroupRef.current({ groupId })
41934231
}, [])
41944232

4195-
/**
4196-
* Computes the names slated for deletion given a click on `columnName` and
4197-
* the current column selection. If the click landed inside a multi-column
4198-
* selection, the entire selection is the target; otherwise it's just the
4199-
* clicked column.
4200-
*/
42014233
const resolveDeletionNames = useCallback((columnName: string): string[] => {
42024234
const cols = columnsRef.current
42034235
if (isColumnSelectionRef.current && selectionAnchorRef.current) {

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export function Table({
231231
const { otherUsers: presenceUsers, remoteSelections, emitCellSelection } = useTableRoom(tableId)
232232

233233
const [slideout, dispatchSlideout] = useReducer(slideoutReducer, { kind: 'none' })
234+
const draftSelectSavingRef = useRef(false)
234235
/**
235236
* Sink the grid populates with its column-draft discard. A select draft
236237
* exists only while its options sidebar is open, so every slideout
@@ -240,6 +241,7 @@ export function Table({
240241
*/
241242
const abortColumnDraftSinkRef = useRef<(() => void) | null>(null)
242243
const dispatch = useCallback((action: SlideoutAction) => {
244+
if (draftSelectSavingRef.current) return
243245
dispatchSlideout(action)
244246
if (action.type !== 'OPEN_COLUMN' || action.config.mode !== 'draft-select') {
245247
abortColumnDraftSinkRef.current?.()
@@ -367,9 +369,28 @@ export function Table({
367369
((options: SelectOption[], multiple: boolean) => Promise<boolean>) | null
368370
>(null)
369371
const onDraftSelectSave = async (options: SelectOption[], multiple: boolean) => {
370-
const created = (await draftSelectSaveSinkRef.current?.(options, multiple)) ?? false
371-
if (created) dispatch({ type: 'CLOSE' })
372-
return created
372+
if (draftSelectSavingRef.current) return false
373+
draftSelectSavingRef.current = true
374+
let created = false
375+
try {
376+
created = (await draftSelectSaveSinkRef.current?.(options, multiple)) ?? false
377+
return created
378+
} finally {
379+
draftSelectSavingRef.current = false
380+
if (created) dispatch({ type: 'CLOSE' })
381+
}
382+
}
383+
384+
const recordColumnTypeChangeSinkRef = useRef<
385+
| ((columnName: string, previousColumn: ColumnDefinition, newColumn: ColumnDefinition) => void)
386+
| null
387+
>(null)
388+
const onColumnTypeChange = (
389+
columnName: string,
390+
previousColumn: ColumnDefinition,
391+
newColumn: ColumnDefinition
392+
) => {
393+
recordColumnTypeChangeSinkRef.current?.(columnName, previousColumn, newColumn)
373394
}
374395

375396
/**
@@ -1644,6 +1665,7 @@ export function Table({
16441665
columnRenameSinkRef={columnRenameSinkRef}
16451666
addColumnOfTypeSinkRef={addColumnOfTypeSinkRef}
16461667
draftSelectSaveSinkRef={draftSelectSaveSinkRef}
1668+
recordColumnTypeChangeSinkRef={recordColumnTypeChangeSinkRef}
16471669
abortColumnDraftSinkRef={abortColumnDraftSinkRef}
16481670
layoutSnapshotSinkRef={layoutSnapshotRef}
16491671
afterDeleteRowsSinkRef={afterDeleteRowsSinkRef}
@@ -1718,6 +1740,7 @@ export function Table({
17181740
config={columnConfig}
17191741
onClose={onCloseSlideout}
17201742
onDraftSave={onDraftSelectSave}
1743+
onColumnTypeChange={onColumnTypeChange}
17211744
existingColumn={
17221745
columnConfig && columnConfig.mode !== 'draft-select'
17231746
? (columns.find((c) => getColumnId(c) === columnConfig.columnName) ?? null)

apps/sim/hooks/queries/tables.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,7 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext)
647647
toast.error(error.message, { duration: 5000 })
648648
},
649649
onSettled: () => {
650-
invalidateTableSchemaOnly(queryClient, tableId)
650+
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
651651
},
652652
})
653653
}

0 commit comments

Comments
 (0)