Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,7 @@ vi.mock('@sim/emcn', () => ({
},
ChipInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
FieldDivider: () => <hr />,
Label: ({ children, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label htmlFor={props.htmlFor ?? 'test-field'} {...props}>
{children}
</label>
),
Label: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Switch: ({ checked }: { checked?: boolean }) => (
<button type='button' aria-pressed={checked}>
Toggle
Expand Down Expand Up @@ -107,12 +103,6 @@ function findButton(label: string): HTMLButtonElement | undefined {
)
}

function setInputValue(input: HTMLInputElement, value: string): void {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
valueSetter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
Expand Down Expand Up @@ -200,8 +190,7 @@ describe('ColumnConfigSidebar', () => {
expect(mockUpdateColumn).not.toHaveBeenCalled()
})

it('edits a Reference column name and target table together', async () => {
const onColumnRename = vi.fn()
it('edits Reference configuration without exposing column renaming', async () => {
await act(async () => {
root.render(
<ColumnConfigSidebar
Expand All @@ -217,27 +206,21 @@ describe('ColumnConfigSidebar', () => {
tableRowTtlEnabled={false}
workspaceId='workspace-1'
tableId='table-current'
onColumnRename={onColumnRename}
referenceColumnsEnabled
/>
)
})

const nameInput = container.querySelector<HTMLInputElement>('#column-sidebar-name')
expect(nameInput?.value).toBe('Related row')
expect(container).not.toHaveTextContent('Column name')
expect(container.querySelector('#column-sidebar-name')).toBeNull()

act(() => setInputValue(nameInput!, 'Renamed relation'))
act(() => findCombobox('Select table')?.onChange?.('table-customers'))
await act(async () => findButton('Save')?.click())

expect(mockUpdateColumn).toHaveBeenCalledWith({
columnName: 'col-reference',
updates: {
name: 'Renamed relation',
referenceTableId: 'table-customers',
},
updates: { referenceTableId: 'table-customers' },
})
expect(onColumnRename).toHaveBeenCalledWith('col-reference', 'Renamed relation')
})

it('keeps an existing Reference column visible but not retargetable when disabled', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,6 @@ interface ColumnConfigSidebarProps {
readOnly?: boolean
/** Why saving is unavailable; surfaced on the disabled Save button. */
readOnlyReason?: string
/** Notify parent of a rename so it can rewrite local `columnOrder` /
* `columnWidths` keys that reference the old name. */
onColumnRename?: (oldName: string, newName: string) => void
}

/**
Expand Down Expand Up @@ -132,7 +129,6 @@ function ColumnConfigBody({
tableId,
readOnly,
readOnlyReason,
onColumnRename,
}: ColumnConfigBodyProps) {
const updateColumn = useUpdateColumn({ workspaceId, tableId })
const addColumn = useAddTableColumn({ workspaceId, tableId })
Expand Down Expand Up @@ -210,7 +206,7 @@ function ColumnConfigBody({
async function handleSave() {
// Belt and braces: the button is disabled, and the server refuses too.
if (readOnly) return
if (!trimmedName) {
if (config.mode === 'create' && !trimmedName) {
setShowValidation(true)
return
}
Expand Down Expand Up @@ -242,7 +238,6 @@ function ColumnConfigBody({
return
}

const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
const uniqueChanged =
supportsUnique && !!existingColumn && !!existingColumn.unique !== uniqueInput
Expand All @@ -256,15 +251,13 @@ function ColumnConfigBody({
wantsReference && existingColumn?.referenceTableId !== referenceTableInput

const updates: {
name?: string
type?: ColumnDefinition['type']
unique?: boolean
options?: SelectOption[]
multiple?: boolean
currencyCode?: string
referenceTableId?: string
} = {
...(renamed ? { name: trimmedName } : {}),
...(typeChanged ? { type: typeInput } : {}),
...(uniqueChanged ? { unique: uniqueInput } : {}),
...(uniqueCleared ? { unique: false } : {}),
Expand All @@ -283,8 +276,7 @@ function ColumnConfigBody({
}

await updateColumn.mutateAsync({ columnName: config.columnName, updates })
if (renamed) onColumnRename?.(config.columnName, trimmedName)
toast.success(`Saved "${trimmedName}"`)
toast.success(`Saved "${existingColumn?.name ?? config.columnName}"`)
onClose()
} catch (err) {
if (isValidationError(err)) {
Expand Down Expand Up @@ -321,23 +313,25 @@ function ColumnConfigBody({
including the comboboxes' trigger buttons; `contents` keeps the
existing layout. Values stay readable and selectable. */}
<fieldset disabled={readOnly} className='contents'>
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel htmlFor='column-sidebar-name'>Column name</RequiredLabel>
<ChipInput
id='column-sidebar-name'
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value)
if (nameError) setNameError(null)
}}
spellCheck={false}
autoComplete='off'
error={Boolean((showValidation && !trimmedName) || nameError)}
aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined}
/>
{showValidation && !trimmedName && <FieldError message='Column name is required' />}
{nameError && !(showValidation && !trimmedName) && <FieldError message={nameError} />}
</div>
{config.mode === 'create' && (
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel htmlFor='column-sidebar-name'>Column name</RequiredLabel>
<ChipInput
id='column-sidebar-name'
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value)
if (nameError) setNameError(null)
}}
spellCheck={false}
autoComplete='off'
error={Boolean((showValidation && !trimmedName) || nameError)}
aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined}
/>
{showValidation && !trimmedName && <FieldError message='Column name is required' />}
{nameError && !(showValidation && !trimmedName) && <FieldError message={nameError} />}
</div>
)}

{config.mode === 'edit' && (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
persistColumnRename,
tryStartColumnRename,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename'
import { useInlineRename } from '@/hooks/use-inline-rename'

interface Deferred {
promise: Promise<void>
resolve: () => void
}

function createDeferred(): Deferred {
let resolve = () => {}
const promise = new Promise<void>((settle) => {
resolve = settle
})
return { promise, resolve }
}

describe('column rename persistence', () => {
it('does not register undo history when persistence rejects', async () => {
const error = new Error('rename rejected')
const pushUndo = vi.fn()
const onRenamed = vi.fn()

await expect(
persistColumnRename({
columnId: 'column-1',
oldName: 'Original',
newName: 'Updated',
persist: () => Promise.reject(error),
pushUndo,
onRenamed,
})
).rejects.toBe(error)

expect(pushUndo).not.toHaveBeenCalled()
expect(onRenamed).not.toHaveBeenCalled()
})
})

describe('column rename sessions', () => {
let container: HTMLDivElement
let root: Root
let rename: ReturnType<typeof useInlineRename>

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

it('refuses a second session until the pending rename settles', async () => {
const deferred = createDeferred()

function Harness() {
rename = useInlineRename({ onSave: () => deferred.promise })
return null
}

act(() => root.render(<Harness />))
act(() => {
expect(tryStartColumnRename(rename, 'column-1', 'First')).toBe(true)
})
act(() => rename.setEditValue('Renamed first'))

let pendingRename: Promise<void>
act(() => {
pendingRename = rename.submitRename()
})

expect(rename.isSaving).toBe(true)
act(() => {
expect(tryStartColumnRename(rename, 'column-2', 'Second')).toBe(false)
})
expect(rename.editingId).toBe('column-1')

await act(async () => {
deferred.resolve()
await pendingRename
})

expect(rename.isSaving).toBe(false)
act(() => {
expect(tryStartColumnRename(rename, 'column-2', 'Second')).toBe(true)
})
expect(rename.editingId).toBe('column-2')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { TableUndoAction } from '@/stores/table/types'

type RenameColumnUndoAction = Extract<TableUndoAction, { type: 'rename-column' }>

interface PersistColumnRenameOptions {
columnId: string
oldName: string
newName: string
persist: () => Promise<unknown>
pushUndo: (action: RenameColumnUndoAction) => void
onRenamed: () => void
}

export async function persistColumnRename({
columnId,
oldName,
newName,
persist,
pushUndo,
onRenamed,
}: PersistColumnRenameOptions): Promise<void> {
await persist()
pushUndo({ type: 'rename-column', oldName, newName, columnId })
onRenamed()
}

interface InlineRenameSession {
isSaving: boolean
startRename: (id: string, currentName: string) => void
}

export function tryStartColumnRename(
session: InlineRenameSession,
columnId: string,
currentName: string
): boolean {
if (session.isSaving) return false
session.startRename(columnId, currentName)
return true
}
Loading