Skip to content
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
'use client'

import { useState } from 'react'
import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast } from '@sim/emcn'
import {
Button,
ChipCombobox,
ChipInput,
cn,
FieldDivider,
Label,
Switch,
Tooltip,
toast,
} from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
Expand Down Expand Up @@ -59,6 +69,15 @@ interface ColumnConfigSidebarProps {
/** Notify parent of a rename so it can rewrite local `columnOrder` /
* `columnWidths` keys that reference the old name. */
onColumnRename?: (oldName: string, newName: string) => void
/**
* Opens the panel for reading only — every field is inert and Save is
* disabled behind {@link readOnlyReason}. The header click that opens this
* sidebar is a primary affordance, so a schema-locked (or read-only) table
* shows the column's settings rather than swallowing the click.
*/
readOnly?: boolean
/** Why saving is unavailable; surfaced on the disabled Save button. */
readOnlyReason?: string
}

/**
Expand Down Expand Up @@ -109,6 +128,8 @@ function ColumnConfigBody({
workspaceId,
tableId,
onColumnRename,
readOnly,
readOnlyReason,
}: ColumnConfigBodyProps) {
const updateColumn = useUpdateColumn({ workspaceId, tableId })
const addColumn = useAddTableColumn({ workspaceId, tableId })
Expand Down Expand Up @@ -154,6 +175,8 @@ function ColumnConfigBody({
}

async function handleSave() {
// Belt and braces: the button is disabled, and the server refuses too.
if (readOnly) return
if (!trimmedName) {
setShowValidation(true)
return
Expand Down Expand Up @@ -254,118 +277,136 @@ function ColumnConfigBody({
</div>

<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2 [overflow-anchor:none]'>
<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' && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Type</RequiredLabel>
<ChipCombobox
options={columnTypeOptionsForTable(allColumns, existingColumn, {
tableRowTtlEnabled,
})
.filter((option) => option.type !== 'workflow')
.map((option) => ({
label: option.label,
value: option.type,
icon: option.icon,
disabled: option.disabledReason !== undefined,
}))}
value={typeInput}
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
placeholder='Select type'
maxHeight={300}
/>
</div>
</>
)}
{/* `disabled` on the fieldset reaches every native control inside,
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>

{wantsCurrency && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Currency</RequiredLabel>
<ChipCombobox
options={CURRENCY_COMBOBOX_OPTIONS}
value={currencyInput}
onChange={setCurrencyInput}
placeholder='Select currency'
searchable
searchPlaceholder='Search currencies'
maxHeight={260}
/>
</div>
</>
)}
{config.mode === 'edit' && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Type</RequiredLabel>
<ChipCombobox
options={columnTypeOptionsForTable(allColumns, existingColumn, {
tableRowTtlEnabled,
})
.filter((option) => option.type !== 'workflow')
.map((option) => ({
label: option.label,
value: option.type,
icon: option.icon,
disabled: option.disabledReason !== undefined,
}))}
value={typeInput}
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
placeholder='Select type'
maxHeight={300}
/>
</div>
</>
)}

{wantsOptions && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Options</RequiredLabel>
<SelectOptionsEditor
options={optionsInput}
onChange={(next) => {
setOptionsInput(next)
if (optionsError) setOptionsError(null)
}}
/>
{optionsError && <FieldError message={optionsError} />}
</div>
<FieldDivider />
<div className='flex items-center justify-between pl-0.5'>
<Label htmlFor='column-sidebar-multiple'>Multiselect</Label>
<Switch
id='column-sidebar-multiple'
checked={multipleInput}
onCheckedChange={(v) => setMultipleInput(!!v)}
/>
</div>
</>
)}
{wantsCurrency && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Currency</RequiredLabel>
<ChipCombobox
options={CURRENCY_COMBOBOX_OPTIONS}
value={currencyInput}
onChange={setCurrencyInput}
placeholder='Select currency'
searchable
searchPlaceholder='Search currencies'
maxHeight={260}
/>
</div>
</>
)}

{/* Select columns don't expose a unique constraint. */}
{!wantsOptions && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
{wantsOptions && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Options</RequiredLabel>
<SelectOptionsEditor
options={optionsInput}
onChange={(next) => {
setOptionsInput(next)
if (optionsError) setOptionsError(null)
}}
/>
{optionsError && <FieldError message={optionsError} />}
</div>
<FieldDivider />
<div className='flex items-center justify-between pl-0.5'>
<Label htmlFor='column-sidebar-unique'>Unique</Label>
<Label htmlFor='column-sidebar-multiple'>Multiselect</Label>
<Switch
id='column-sidebar-unique'
checked={uniqueInput}
onCheckedChange={(v) => setUniqueInput(!!v)}
id='column-sidebar-multiple'
checked={multipleInput}
onCheckedChange={(v) => setMultipleInput(!!v)}
/>
</div>
</div>
</>
)}
</>
)}

{/* Select columns don't expose a unique constraint. */}
{!wantsOptions && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<div className='flex items-center justify-between pl-0.5'>
<Label htmlFor='column-sidebar-unique'>Unique</Label>
<Switch
id='column-sidebar-unique'
checked={uniqueInput}
onCheckedChange={(v) => setUniqueInput(!!v)}
/>
</div>
</div>
</>
)}
</fieldset>
</div>

<div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'>
<Button variant='default' size='sm' onClick={onClose}>
Cancel
</Button>
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>
{saveDisabled ? 'Saving…' : 'Save'}
{readOnly ? 'Close' : 'Cancel'}
</Button>
{readOnly ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button variant='primary' size='sm' disabled>
Save
</Button>
</span>
</Tooltip.Trigger>
{readOnlyReason && <Tooltip.Content>{readOnlyReason}</Tooltip.Content>}
</Tooltip.Root>
) : (
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>
{saveDisabled ? 'Saving…' : 'Save'}
</Button>
)}
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,36 @@ afterEach(() => {
})

describe('ColumnDropdown', () => {
it('keeps a schema-locked trigger focusable for its explanation without opening a menu', () => {
const onPickType = vi.fn()
act(() => {
root.render(
<ColumnDropdown
columns={[]}
tableRowTtlEnabled
trigger='header'
disabled={false}
blocked
onPickType={onPickType}
onPickWorkflow={vi.fn()}
onPickEnrichment={vi.fn()}
/>
)
})
const trigger = container.querySelector<HTMLButtonElement>('button')!
expect(trigger.getAttribute('aria-disabled')).toBe('true')
expect(trigger.disabled).toBe(false)
act(() => {
trigger.focus()
trigger.click()
})
expect(document.querySelector('[role="tooltip"]')?.textContent).toContain(
'Changing the table schema is disabled in Table Security.'
)
expect(document.querySelector('[role="menu"]')).toBeNull()
expect(onPickType).not.toHaveBeenCalled()
})

it('lists Enrichments as a regular entry after the column options', () => {
const onPickEnrichment = vi.fn()

Expand All @@ -37,7 +67,6 @@ describe('ColumnDropdown', () => {
onPickWorkflow={vi.fn()}
onPickEnrichment={onPickEnrichment}
blocked={false}
onBlocked={vi.fn()}
/>
)
})
Expand Down
Loading
Loading