Skip to content
Merged
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 @@ -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 (GMT-07:00)', 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: Los Angeles (GMT-07:00)', 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,62 @@
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)
const browserTimezoneLabel =
timezoneOptions.find((option) => option.value === browserTimezone)?.label ??
sanitizeTimezoneForDisplay(browserTimezone)

return {
value: hasInvalidTimezone
? INVALID_TIMEZONE_OPTION_VALUE
: (savedTimezone ?? AUTO_TIMEZONE_OPTION_VALUE),
options: [
{ label: `Auto: ${browserTimezoneLabel}`, 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