Skip to content

Commit f41e27a

Browse files
authored
fix(tables): harden timezone and expiration handling (#7291)
* fix(timezone): preserve low-year wall clocks Avoid Date.UTC's 1900 remapping and retain four-digit years through date and TTL editing. * fix(timezone): fall back from invalid saved zones * fix(tables): report rejected TTL imports * fix(timezone): reject empty zone identifiers * fix(tables): dispatch row delete triggers asynchronously * fix(tables): cap rows to delete snapshot budget * fix(tables): signal partial TTL cleanup changes * fix(tables): wait for timezone before date edits * fix(tables): preserve blank TTL values * fix(tables): guard date edits against invalid timezones * fix(tables): address timezone and delete review findings * fix(timezone): format automatic timezone label
1 parent ddab4a9 commit f41e27a

32 files changed

Lines changed: 1194 additions & 145 deletions

apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import {
3333
generalViewParam,
3434
generalViewUrlKeys,
3535
} from '@/app/workspace/[workspaceId]/settings/components/general/search-params'
36+
import {
37+
getTimezonePickerPresentation,
38+
timezonePreferenceFromPickerValue,
39+
} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker'
3640
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
3741
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
3842
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -221,7 +225,12 @@ export function General() {
221225
}
222226

223227
const handleTimezoneChange = async (value: string) => {
224-
await updateSetting.mutateAsync({ key: 'timezone', value })
228+
const timezone = timezonePreferenceFromPickerValue(value)
229+
if (timezone === undefined) return
230+
await updateSetting.mutateAsync({
231+
key: 'timezone',
232+
value: timezone,
233+
})
225234
}
226235

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

300+
const browserTimezone = getBrowserTimezone()
301+
const savedTimezone = settings?.timezone ?? null
302+
const timezonePicker = getTimezonePickerPresentation(
303+
savedTimezone,
304+
browserTimezone,
305+
TIMEZONE_OPTIONS
306+
)
307+
291308
return (
292309
<>
293310
<SettingsPanel actions={actions}>
@@ -433,10 +450,10 @@ export function General() {
433450
dropdownWidth={240}
434451
searchable
435452
searchPlaceholder='Search timezones'
436-
value={settings?.timezone ?? getBrowserTimezone()}
453+
value={timezonePicker.value}
437454
onChange={handleTimezoneChange}
438455
placeholder='Select timezone'
439-
options={TIMEZONE_OPTIONS}
456+
options={timezonePicker.options}
440457
/>
441458
</div>
442459
</div>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
AUTO_TIMEZONE_OPTION_VALUE,
7+
getTimezonePickerPresentation,
8+
INVALID_TIMEZONE_OPTION_VALUE,
9+
timezonePreferenceFromPickerValue,
10+
} from '@/app/workspace/[workspaceId]/settings/components/general/timezone-picker'
11+
12+
const timezoneOptions = [{ label: 'Los Angeles (GMT-07:00)', value: 'America/Los_Angeles' }]
13+
14+
describe('getTimezonePickerPresentation', () => {
15+
it('shows an unset preference as an explicit browser-managed option', () => {
16+
expect(getTimezonePickerPresentation(null, 'America/Los_Angeles', timezoneOptions)).toEqual({
17+
value: AUTO_TIMEZONE_OPTION_VALUE,
18+
options: [
19+
{ label: 'Auto: Los Angeles (GMT-07:00)', value: AUTO_TIMEZONE_OPTION_VALUE },
20+
...timezoneOptions,
21+
],
22+
})
23+
})
24+
25+
it('keeps a valid saved timezone selected independently of Auto', () => {
26+
expect(
27+
getTimezonePickerPresentation('America/Los_Angeles', 'America/Los_Angeles', timezoneOptions)
28+
.value
29+
).toBe('America/Los_Angeles')
30+
})
31+
32+
it('adds a valid saved timezone that is absent from the curated options', () => {
33+
expect(getTimezonePickerPresentation('Etc/GMT+5', 'UTC', timezoneOptions)).toEqual({
34+
value: 'Etc/GMT+5',
35+
options: [
36+
{ label: 'Auto: UTC', value: AUTO_TIMEZONE_OPTION_VALUE },
37+
{ label: 'Etc/GMT+5', value: 'Etc/GMT+5' },
38+
...timezoneOptions,
39+
],
40+
})
41+
})
42+
43+
it('surfaces an invalid saved timezone without making it selectable', () => {
44+
expect(getTimezonePickerPresentation('Mars/Olympus', 'UTC', timezoneOptions)).toEqual({
45+
value: INVALID_TIMEZONE_OPTION_VALUE,
46+
options: [
47+
{ label: 'Auto: UTC', value: AUTO_TIMEZONE_OPTION_VALUE },
48+
{
49+
label: 'Invalid: Mars/Olympus',
50+
value: INVALID_TIMEZONE_OPTION_VALUE,
51+
disabled: true,
52+
},
53+
...timezoneOptions,
54+
],
55+
})
56+
})
57+
58+
it('persists Auto as an unset preference', () => {
59+
expect(timezonePreferenceFromPickerValue(AUTO_TIMEZONE_OPTION_VALUE)).toBeNull()
60+
expect(timezonePreferenceFromPickerValue('Asia/Tokyo')).toBe('Asia/Tokyo')
61+
expect(timezonePreferenceFromPickerValue(INVALID_TIMEZONE_OPTION_VALUE)).toBeUndefined()
62+
})
63+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { ComboboxOption } from '@sim/emcn'
2+
import { isValidTimezone, sanitizeTimezoneForDisplay } from '@/lib/core/utils/timezone'
3+
4+
export const AUTO_TIMEZONE_OPTION_VALUE = '__auto_timezone__'
5+
export const INVALID_TIMEZONE_OPTION_VALUE = '__invalid_timezone__'
6+
7+
interface TimezonePickerPresentation {
8+
value: string
9+
options: ComboboxOption[]
10+
}
11+
12+
/** Builds the picker state without making an unset browser fallback look persisted. */
13+
export function getTimezonePickerPresentation(
14+
savedTimezone: string | null,
15+
browserTimezone: string,
16+
timezoneOptions: readonly ComboboxOption[]
17+
): TimezonePickerPresentation {
18+
const hasInvalidTimezone = savedTimezone !== null && !isValidTimezone(savedTimezone)
19+
const unlistedTimezone =
20+
savedTimezone !== null &&
21+
!hasInvalidTimezone &&
22+
!timezoneOptions.some((option) => option.value === savedTimezone)
23+
? savedTimezone
24+
: null
25+
const safeInvalidTimezone =
26+
savedTimezone === null ? '' : sanitizeTimezoneForDisplay(savedTimezone)
27+
const browserTimezoneLabel =
28+
timezoneOptions.find((option) => option.value === browserTimezone)?.label ??
29+
sanitizeTimezoneForDisplay(browserTimezone)
30+
31+
return {
32+
value: hasInvalidTimezone
33+
? INVALID_TIMEZONE_OPTION_VALUE
34+
: (savedTimezone ?? AUTO_TIMEZONE_OPTION_VALUE),
35+
options: [
36+
{ label: `Auto: ${browserTimezoneLabel}`, value: AUTO_TIMEZONE_OPTION_VALUE },
37+
...(hasInvalidTimezone
38+
? [
39+
{
40+
label: `Invalid: ${safeInvalidTimezone || '(empty)'}`,
41+
value: INVALID_TIMEZONE_OPTION_VALUE,
42+
disabled: true,
43+
},
44+
]
45+
: []),
46+
...(unlistedTimezone
47+
? [
48+
{
49+
label: sanitizeTimezoneForDisplay(unlistedTimezone),
50+
value: unlistedTimezone,
51+
},
52+
]
53+
: []),
54+
...timezoneOptions,
55+
],
56+
}
57+
}
58+
59+
export function timezonePreferenceFromPickerValue(value: string): string | null | undefined {
60+
if (value === INVALID_TIMEZONE_OPTION_VALUE) return undefined
61+
return value === AUTO_TIMEZONE_OPTION_VALUE ? null : value
62+
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import type { TableInfo, TableRow } from '@/lib/table'
88
import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal'
99

10-
const { mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = vi.hoisted(() => ({
11-
mockUseTimezoneState: vi.fn(),
12-
mockUpdateRow: vi.fn(),
13-
mockDeleteRow: vi.fn(),
14-
mockDeleteRows: vi.fn(),
15-
}))
10+
const { mockToastError, mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } =
11+
vi.hoisted(() => ({
12+
mockToastError: vi.fn(),
13+
mockUseTimezoneState: vi.fn(),
14+
mockUpdateRow: vi.fn(),
15+
mockDeleteRow: vi.fn(),
16+
mockDeleteRows: vi.fn(),
17+
}))
1618

1719
vi.mock('next/navigation', () => ({
1820
useParams: () => ({ workspaceId: 'workspace-1' }),
@@ -29,6 +31,8 @@ vi.mock('@sim/emcn', () => {
2931
const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
3032
return {
3133
Checkbox: () => null,
34+
Chip: ({ children, ...props }: { children?: ReactNode }) =>
35+
createElement('button', { type: 'button', ...props }, children),
3236
ChipConfirmModal: passthrough,
3337
ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
3438
createElement(
@@ -39,7 +43,27 @@ vi.mock('@sim/emcn', () => {
3943
ChipModal: passthrough,
4044
ChipModalBody: passthrough,
4145
ChipModalError: passthrough,
42-
ChipModalField: passthrough,
46+
ChipModalField: ({
47+
type,
48+
value,
49+
onChange,
50+
children,
51+
}: {
52+
type?: string
53+
value?: string
54+
onChange?: (value: string) => void
55+
children?: ReactNode | ((aria: Record<string, string>) => ReactNode)
56+
}) =>
57+
type === 'input'
58+
? createElement('input', {
59+
'data-testid': 'modal-input',
60+
value: value ?? '',
61+
onChange: (event: { currentTarget: { value: string } }) =>
62+
onChange?.(event.currentTarget.value),
63+
})
64+
: typeof children === 'function'
65+
? children({ 'aria-describedby': 'field-hint' })
66+
: (children ?? null),
4367
ChipModalFooter: ({
4468
primaryAction,
4569
}: {
@@ -64,6 +88,7 @@ vi.mock('@sim/emcn', () => {
6488
onChange(event.currentTarget.value),
6589
}),
6690
Label: passthrough,
91+
toast: { error: mockToastError },
6792
}
6893
})
6994

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

114-
expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…')
139+
expect(container.querySelector('[aria-label="Edit expires_at"]')?.textContent).toBe(
140+
'Loading timezone…'
141+
)
115142
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()
116143
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
117144
true
@@ -145,4 +172,129 @@ describe('RowModal expiration editing', () => {
145172
act(() => root.unmount())
146173
container.remove()
147174
})
175+
176+
it('also waits for timezone settings on an ordinary Date column', () => {
177+
mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
178+
const container = document.createElement('div')
179+
document.body.appendChild(container)
180+
const root = createRoot(container)
181+
const props = {
182+
mode: 'edit' as const,
183+
isOpen: true,
184+
onClose: vi.fn(),
185+
table: {
186+
id: 'table-2',
187+
name: 'Dates',
188+
schema: { columns: [{ name: 'starts_at', type: 'date' as const }] },
189+
},
190+
row: { ...row, data: { starts_at: '2026-06-15T09:00:00+09:00' } },
191+
onSuccess: vi.fn(),
192+
}
193+
194+
act(() => root.render(createElement(RowModal, props)))
195+
196+
expect(container.querySelector('[aria-label="Edit starts_at"]')?.textContent).toBe(
197+
'Loading timezone…'
198+
)
199+
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()
200+
201+
mockUseTimezoneState.mockReturnValue({
202+
timezone: 'America/Los_Angeles',
203+
status: 'ready',
204+
})
205+
act(() => root.render(createElement(RowModal, props)))
206+
207+
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).not.toBeNull()
208+
act(() => root.unmount())
209+
container.remove()
210+
})
211+
212+
it('blocks an invalid saved timezone with the plain-text guidance', () => {
213+
mockUseTimezoneState.mockReturnValue({
214+
timezone: 'America/Los_Angeles',
215+
savedTimezone: 'Mars/Olympus',
216+
status: 'invalid',
217+
})
218+
const container = document.createElement('div')
219+
document.body.appendChild(container)
220+
const root = createRoot(container)
221+
const props = {
222+
mode: 'edit' as const,
223+
isOpen: true,
224+
onClose: vi.fn(),
225+
table,
226+
row,
227+
onSuccess: vi.fn(),
228+
}
229+
230+
act(() => root.render(createElement(RowModal, props)))
231+
232+
const blockedField = container.querySelector<HTMLButtonElement>(
233+
'[aria-label="Edit expires_at"]'
234+
)
235+
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
236+
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
237+
true
238+
)
239+
expect(mockToastError).not.toHaveBeenCalled()
240+
act(() => blockedField?.click())
241+
expect(mockToastError).toHaveBeenCalledWith(
242+
'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.'
243+
)
244+
act(() => root.unmount())
245+
container.remove()
246+
})
247+
248+
it('keeps unrelated fields editable and omits blocked date values from the update', async () => {
249+
mockUseTimezoneState.mockReturnValue({
250+
timezone: 'America/Los_Angeles',
251+
savedTimezone: 'Mars/Olympus',
252+
status: 'invalid',
253+
})
254+
const container = document.createElement('div')
255+
document.body.appendChild(container)
256+
const root = createRoot(container)
257+
const mixedTable: TableInfo = {
258+
...table,
259+
schema: {
260+
columns: [
261+
{ name: 'name', type: 'string' },
262+
{ name: 'expires_at', type: 'ttl' },
263+
],
264+
},
265+
}
266+
const mixedRow = { ...row, data: { name: 'Ada', expires_at: row.data.expires_at } }
267+
const props = {
268+
mode: 'edit' as const,
269+
isOpen: true,
270+
onClose: vi.fn(),
271+
table: mixedTable,
272+
row: mixedRow,
273+
onSuccess: vi.fn(),
274+
}
275+
276+
act(() => root.render(createElement(RowModal, props)))
277+
278+
const nameInput = container.querySelector<HTMLInputElement>('[data-testid="modal-input"]')
279+
const blockedField = container.querySelector<HTMLButtonElement>(
280+
'[aria-label="Edit expires_at"]'
281+
)
282+
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
283+
expect(nameInput?.value).toBe('Ada')
284+
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
285+
expect(submit?.disabled).toBe(false)
286+
287+
act(() => changeInput(nameInput as HTMLInputElement, 'Grace'))
288+
await act(async () => submit?.click())
289+
290+
expect(mockUpdateRow).toHaveBeenCalledWith({
291+
rowId: 'row-1',
292+
data: { name: 'Grace' },
293+
})
294+
expect(props.onSuccess).toHaveBeenCalledTimes(1)
295+
expect(mockToastError).not.toHaveBeenCalled()
296+
297+
act(() => root.unmount())
298+
container.remove()
299+
})
148300
})

0 commit comments

Comments
 (0)