diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index bb6758aec4d..faa87c11096 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -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' @@ -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) => { @@ -288,6 +297,14 @@ export function General() { return } + const browserTimezone = getBrowserTimezone() + const savedTimezone = settings?.timezone ?? null + const timezonePicker = getTimezonePickerPresentation( + savedTimezone, + browserTimezone, + TIMEZONE_OPTIONS + ) + return ( <> @@ -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} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts new file mode 100644 index 00000000000..9b336fe5cb2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.test.ts @@ -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() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts new file mode 100644 index 00000000000..ed2abe68e0a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/timezone-picker.ts @@ -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, + ], + } +} + +export function timezonePreferenceFromPickerValue(value: string): string | null | undefined { + if (value === INVALID_TIMEZONE_OPTION_VALUE) return undefined + return value === AUTO_TIMEZONE_OPTION_VALUE ? null : value +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx index 851a755b22a..1c0a37a21c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx @@ -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' }), @@ -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( @@ -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) => 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, }: { @@ -64,6 +88,7 @@ vi.mock('@sim/emcn', () => { onChange(event.currentTarget.value), }), Label: passthrough, + toast: { error: mockToastError }, } }) @@ -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('[data-testid="time"]')).toBeNull() expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe( true @@ -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('[data-testid="time"]')).toBeNull() + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + act(() => root.render(createElement(RowModal, props))) + + expect(container.querySelector('[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( + '[aria-label="Edit expires_at"]' + ) + expect(blockedField?.textContent).toBe(String(row.data.expires_at)) + expect(container.querySelector('[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('[data-testid="modal-input"]') + const blockedField = container.querySelector( + '[aria-label="Edit expires_at"]' + ) + const submit = container.querySelector('[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() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index e139b38b849..bbab5f353f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -3,6 +3,7 @@ import { useId, useRef, useState } from 'react' import { Checkbox, + Chip, ChipConfirmModal, ChipDatePicker, ChipModal, @@ -13,6 +14,7 @@ import { ChipModalHeader, ChipTimePicker, Label, + toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -20,7 +22,8 @@ import { useParams } from 'next/navigation' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' import { columnTypeOf } from '@/lib/table/column-types' import { resolveCurrencyCode } from '@/lib/table/currency' -import { useTimezoneState } from '@/hooks/queries/general-settings' +import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing' +import { type TimezoneState, useTimezoneState } from '@/hooks/queries/general-settings' import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables' import { cleanCellValue, @@ -46,12 +49,16 @@ export interface RowModalProps { function cleanRowData( columns: ColumnDefinition[], rowData: Record, - timeZone: string + timeZone: string, + dateEditorsReady: boolean ): Record { const cleanData: Record = {} columns.forEach((col) => { const value = rowData[col.name] + if (columnTypeOf(col).editor === 'date' && !dateEditorsReady) { + return + } try { cleanData[col.name] = cleanCellValue(value, col, timeZone) } catch { @@ -83,8 +90,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess if (timezoneState.status === 'ready' && editTimeZoneRef.current === null) { editTimeZoneRef.current = timezoneState.timezone } - const hasTtlColumn = mode === 'edit' && columns.some((column) => column.type === 'ttl') - const ttlTimezoneUnavailable = hasTtlColumn && editTimeZoneRef.current === null + const dateEditorsReady = editTimeZoneRef.current !== null const timeZone = editTimeZoneRef.current ?? timezoneState.timezone const [rowData, setRowData] = useState>(() => mode === 'edit' && row ? row.data : {} @@ -96,13 +102,18 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const isSubmitting = updateRowMutation.isPending || deleteRowMutation.isPending || deleteRowsMutation.isPending + const timezoneBlockedMessage = getTimezoneEditBlockedMessage(timezoneState) + const hasEditableColumn = columns.some( + (column) => columnTypeOf(column).editor !== 'date' || dateEditorsReady + ) + const handleFormSubmit = async (e?: React.FormEvent) => { e?.preventDefault() setError(null) - if (ttlTimezoneUnavailable) return + if (!hasEditableColumn) return try { - const cleanData = cleanRowData(columns, rowData, timeZone) + const cleanData = cleanRowData(columns, rowData, timeZone, dateEditorsReady) if (row) { await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData }) @@ -177,13 +188,19 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess Update values for {table?.name ?? 'table'}

-