Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ import {
generalViewParam,
generalViewUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/general/search-params'
import {
getTimezonePickerPresentation,
timezonePreferenceFromPickerValue,
} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
Expand Down Expand Up @@ -221,7 +225,12 @@ export function General() {
}

const handleTimezoneChange = async (value: string) => {
await updateSetting.mutateAsync({ key: 'timezone', value })
const timezone = timezonePreferenceFromPickerValue(value)
if (timezone === undefined) return
await updateSetting.mutateAsync({
key: 'timezone',
value: timezone,
})
}

const handleAutoConnectChange = async (checked: boolean) => {
Expand Down Expand Up @@ -288,6 +297,14 @@ export function General() {
return <SettingsPanel actions={actions} />
}

const browserTimezone = getBrowserTimezone()
const savedTimezone = settings?.timezone ?? null
const timezonePicker = getTimezonePickerPresentation(
savedTimezone,
browserTimezone,
TIMEZONE_OPTIONS
)

return (
<>
<SettingsPanel actions={actions}>
Expand Down Expand Up @@ -433,10 +450,10 @@ export function General() {
dropdownWidth={240}
searchable
searchPlaceholder='Search timezones'
value={settings?.timezone ?? getBrowserTimezone()}
value={timezonePicker.value}
onChange={handleTimezoneChange}
placeholder='Select timezone'
options={TIMEZONE_OPTIONS}
options={timezonePicker.options}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
AUTO_TIMEZONE_OPTION_VALUE,
getTimezonePickerPresentation,
INVALID_TIMEZONE_OPTION_VALUE,
timezonePreferenceFromPickerValue,
} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker'

const timezoneOptions = [{ label: 'Los Angeles', value: 'America/Los_Angeles' }]

describe('getTimezonePickerPresentation', () => {
it('shows an unset preference as an explicit browser-managed option', () => {
expect(getTimezonePickerPresentation(null, 'America/Los_Angeles', timezoneOptions)).toEqual({
value: AUTO_TIMEZONE_OPTION_VALUE,
options: [
{ label: 'Auto: America/Los_Angeles', value: AUTO_TIMEZONE_OPTION_VALUE },
...timezoneOptions,
],
})
})

it('keeps a valid saved timezone selected independently of Auto', () => {
expect(
getTimezonePickerPresentation('America/Los_Angeles', 'America/Los_Angeles', timezoneOptions)
.value
).toBe('America/Los_Angeles')
})

it('adds a valid saved timezone that is absent from the curated options', () => {
expect(getTimezonePickerPresentation('Etc/GMT+5', 'UTC', timezoneOptions)).toEqual({
value: 'Etc/GMT+5',
options: [
{ label: 'Auto: UTC', value: AUTO_TIMEZONE_OPTION_VALUE },
{ label: 'Etc/GMT+5', value: 'Etc/GMT+5' },
...timezoneOptions,
],
})
})

it('surfaces an invalid saved timezone without making it selectable', () => {
expect(getTimezonePickerPresentation('Mars/Olympus', 'UTC', timezoneOptions)).toEqual({
value: INVALID_TIMEZONE_OPTION_VALUE,
options: [
{ label: 'Auto: UTC', value: AUTO_TIMEZONE_OPTION_VALUE },
{
label: 'Invalid: Mars/Olympus',
value: INVALID_TIMEZONE_OPTION_VALUE,
disabled: true,
},
...timezoneOptions,
],
})
})

it('persists Auto as an unset preference', () => {
expect(timezonePreferenceFromPickerValue(AUTO_TIMEZONE_OPTION_VALUE)).toBeNull()
expect(timezonePreferenceFromPickerValue('Asia/Tokyo')).toBe('Asia/Tokyo')
expect(timezonePreferenceFromPickerValue(INVALID_TIMEZONE_OPTION_VALUE)).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ComboboxOption } from '@sim/emcn'
import { isValidTimezone, sanitizeTimezoneForDisplay } from '@/lib/core/utils/timezone'

export const AUTO_TIMEZONE_OPTION_VALUE = '__auto_timezone__'
export const INVALID_TIMEZONE_OPTION_VALUE = '__invalid_timezone__'

interface TimezonePickerPresentation {
value: string
options: ComboboxOption[]
}

/** Builds the picker state without making an unset browser fallback look persisted. */
export function getTimezonePickerPresentation(
savedTimezone: string | null,
browserTimezone: string,
timezoneOptions: readonly ComboboxOption[]
): TimezonePickerPresentation {
const hasInvalidTimezone = savedTimezone !== null && !isValidTimezone(savedTimezone)
const unlistedTimezone =
savedTimezone !== null &&
!hasInvalidTimezone &&
!timezoneOptions.some((option) => option.value === savedTimezone)
? savedTimezone
: null
const safeInvalidTimezone =
savedTimezone === null ? '' : sanitizeTimezoneForDisplay(savedTimezone)

return {
value: hasInvalidTimezone
? INVALID_TIMEZONE_OPTION_VALUE
: (savedTimezone ?? AUTO_TIMEZONE_OPTION_VALUE),
options: [
{ label: `Auto: ${browserTimezone}`, value: AUTO_TIMEZONE_OPTION_VALUE },
...(hasInvalidTimezone
? [
{
label: `Invalid: ${safeInvalidTimezone || '(empty)'}`,
value: INVALID_TIMEZONE_OPTION_VALUE,
disabled: true,
},
]
: []),
...(unlistedTimezone
? [
{
label: sanitizeTimezoneForDisplay(unlistedTimezone),
value: unlistedTimezone,
},
]
: []),
...timezoneOptions,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
],
}
}

export function timezonePreferenceFromPickerValue(value: string): string | null | undefined {
if (value === INVALID_TIMEZONE_OPTION_VALUE) return undefined
return value === AUTO_TIMEZONE_OPTION_VALUE ? null : value
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableInfo, TableRow } from '@/lib/table'
import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal'

const { mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = vi.hoisted(() => ({
mockUseTimezoneState: vi.fn(),
mockUpdateRow: vi.fn(),
mockDeleteRow: vi.fn(),
mockDeleteRows: vi.fn(),
}))
const { mockToastError, mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } =
vi.hoisted(() => ({
mockToastError: vi.fn(),
mockUseTimezoneState: vi.fn(),
mockUpdateRow: vi.fn(),
mockDeleteRow: vi.fn(),
mockDeleteRows: vi.fn(),
}))

vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
Expand All @@ -29,6 +31,8 @@ vi.mock('@sim/emcn', () => {
const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
return {
Checkbox: () => null,
Chip: ({ children, ...props }: { children?: ReactNode }) =>
createElement('button', { type: 'button', ...props }, children),
ChipConfirmModal: passthrough,
ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
createElement(
Expand All @@ -39,7 +43,27 @@ vi.mock('@sim/emcn', () => {
ChipModal: passthrough,
ChipModalBody: passthrough,
ChipModalError: passthrough,
ChipModalField: passthrough,
ChipModalField: ({
type,
value,
onChange,
children,
}: {
type?: string
value?: string
onChange?: (value: string) => void
children?: ReactNode | ((aria: Record<string, string>) => ReactNode)
}) =>
type === 'input'
? createElement('input', {
'data-testid': 'modal-input',
value: value ?? '',
onChange: (event: { currentTarget: { value: string } }) =>
onChange?.(event.currentTarget.value),
})
: typeof children === 'function'
? children({ 'aria-describedby': 'field-hint' })
: (children ?? null),
ChipModalFooter: ({
primaryAction,
}: {
Expand All @@ -64,6 +88,7 @@ vi.mock('@sim/emcn', () => {
onChange(event.currentTarget.value),
}),
Label: passthrough,
toast: { error: mockToastError },
}
})

Expand Down Expand Up @@ -111,7 +136,9 @@ describe('RowModal expiration editing', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
act(() => root.render(createElement(RowModal, props)))

expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…')
expect(container.querySelector('[aria-label="Edit expires_at"]')?.textContent).toBe(
'Loading timezone…'
)
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
true
Expand Down Expand Up @@ -145,4 +172,129 @@ describe('RowModal expiration editing', () => {
act(() => root.unmount())
container.remove()
})

it('also waits for timezone settings on an ordinary Date column', () => {
mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const props = {
mode: 'edit' as const,
isOpen: true,
onClose: vi.fn(),
table: {
id: 'table-2',
name: 'Dates',
schema: { columns: [{ name: 'starts_at', type: 'date' as const }] },
},
row: { ...row, data: { starts_at: '2026-06-15T09:00:00+09:00' } },
onSuccess: vi.fn(),
}

act(() => root.render(createElement(RowModal, props)))

expect(container.querySelector('[aria-label="Edit starts_at"]')?.textContent).toBe(
'Loading timezone…'
)
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()

mockUseTimezoneState.mockReturnValue({
timezone: 'America/Los_Angeles',
status: 'ready',
})
act(() => root.render(createElement(RowModal, props)))

expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).not.toBeNull()
act(() => root.unmount())
container.remove()
})

it('blocks an invalid saved timezone with the plain-text guidance', () => {
mockUseTimezoneState.mockReturnValue({
timezone: 'America/Los_Angeles',
savedTimezone: 'Mars/Olympus',
status: 'invalid',
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const props = {
mode: 'edit' as const,
isOpen: true,
onClose: vi.fn(),
table,
row,
onSuccess: vi.fn(),
}

act(() => root.render(createElement(RowModal, props)))

const blockedField = container.querySelector<HTMLButtonElement>(
'[aria-label="Edit expires_at"]'
)
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
true
)
expect(mockToastError).not.toHaveBeenCalled()
act(() => blockedField?.click())
expect(mockToastError).toHaveBeenCalledWith(
'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.'
)
act(() => root.unmount())
container.remove()
})

it('keeps unrelated fields editable and omits blocked date values from the update', async () => {
mockUseTimezoneState.mockReturnValue({
timezone: 'America/Los_Angeles',
savedTimezone: 'Mars/Olympus',
status: 'invalid',
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const mixedTable: TableInfo = {
...table,
schema: {
columns: [
{ name: 'name', type: 'string' },
{ name: 'expires_at', type: 'ttl' },
],
},
}
const mixedRow = { ...row, data: { name: 'Ada', expires_at: row.data.expires_at } }
const props = {
mode: 'edit' as const,
isOpen: true,
onClose: vi.fn(),
table: mixedTable,
row: mixedRow,
onSuccess: vi.fn(),
}

act(() => root.render(createElement(RowModal, props)))

const nameInput = container.querySelector<HTMLInputElement>('[data-testid="modal-input"]')
const blockedField = container.querySelector<HTMLButtonElement>(
'[aria-label="Edit expires_at"]'
)
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
expect(nameInput?.value).toBe('Ada')
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
expect(submit?.disabled).toBe(false)

act(() => changeInput(nameInput as HTMLInputElement, 'Grace'))
await act(async () => submit?.click())

expect(mockUpdateRow).toHaveBeenCalledWith({
rowId: 'row-1',
data: { name: 'Grace' },
})
expect(props.onSuccess).toHaveBeenCalledTimes(1)
expect(mockToastError).not.toHaveBeenCalled()

act(() => root.unmount())
container.remove()
})
})
Loading
Loading