Skip to content

Commit b80c4df

Browse files
committed
fix(tables): wait for timezone before TTL edits
1 parent 4dccccb commit b80c4df

4 files changed

Lines changed: 250 additions & 19 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import {
1111
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors'
1212
import { cleanCellValue } from '@/app/workspace/[workspaceId]/tables/[tableId]/utils'
1313

14-
const { mockUseTimezone } = vi.hoisted(() => ({ mockUseTimezone: vi.fn() }))
14+
const { mockToastError, mockUseTimezoneState } = vi.hoisted(() => ({
15+
mockToastError: vi.fn(),
16+
mockUseTimezoneState: vi.fn(),
17+
}))
1518

16-
vi.mock('@/hooks/queries/general-settings', () => ({ useTimezone: mockUseTimezone }))
19+
vi.mock('@/hooks/queries/general-settings', () => ({ useTimezoneState: mockUseTimezoneState }))
1720
vi.mock('@sim/emcn', () => {
1821
const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
1922
return {
@@ -26,15 +29,24 @@ vi.mock('@sim/emcn', () => {
2629
Popover: passthrough,
2730
PopoverAnchor: () => null,
2831
PopoverContent: passthrough,
29-
toast: { error: vi.fn() },
32+
toast: { error: mockToastError },
3033
}
3134
})
3235
const column = (type: ColumnDefinition['type']): ColumnDefinition => ({ name: 'expires_at', type })
3336

37+
function changeInput(input: HTMLInputElement, value: string) {
38+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
39+
setter?.call(input, value)
40+
input.dispatchEvent(new Event('input', { bubbles: true }))
41+
}
42+
3443
describe('dateEditorRawValue', () => {
3544
beforeEach(() => {
3645
vi.clearAllMocks()
37-
mockUseTimezone.mockReturnValue('America/Los_Angeles')
46+
mockUseTimezoneState.mockReturnValue({
47+
timezone: 'America/Los_Angeles',
48+
status: 'ready',
49+
})
3850
})
3951

4052
it('leaves TTL drafts for TTL coercion to resolve safely', () => {
@@ -73,7 +85,10 @@ describe('dateEditorRawValue', () => {
7385

7486
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
7587
act(() => root.render(createElement(InlineEditor, props)))
76-
mockUseTimezone.mockReturnValue('America/New_York')
88+
mockUseTimezoneState.mockReturnValue({
89+
timezone: 'America/New_York',
90+
status: 'ready',
91+
})
7792
act(() => root.render(createElement(InlineEditor, props)))
7893

7994
const input = container.querySelector('input')
@@ -86,4 +101,130 @@ describe('dateEditorRawValue', () => {
86101
act(() => root.unmount())
87102
container.remove()
88103
})
104+
105+
it('waits for the saved timezone before creating a TTL draft', () => {
106+
mockUseTimezoneState.mockReturnValue({
107+
timezone: 'Asia/Tokyo',
108+
status: 'loading',
109+
})
110+
const container = document.createElement('div')
111+
document.body.appendChild(container)
112+
const root = createRoot(container)
113+
const onSave = vi.fn()
114+
const props = {
115+
value: Date.parse('2026-06-15T13:00:30Z') / 1000,
116+
column: column('ttl'),
117+
onSave,
118+
onCancel: vi.fn(),
119+
}
120+
121+
act(() => root.render(createElement(InlineEditor, props)))
122+
123+
expect(container.querySelector('input')).toMatchObject({
124+
disabled: true,
125+
placeholder: 'Loading timezone...',
126+
})
127+
128+
mockUseTimezoneState.mockReturnValue({
129+
timezone: 'America/Los_Angeles',
130+
status: 'ready',
131+
})
132+
act(() => root.render(createElement(InlineEditor, props)))
133+
134+
const input = container.querySelector('input') as HTMLInputElement
135+
expect(input.disabled).toBe(false)
136+
act(() => changeInput(input, '09/01/2026 9:00 AM'))
137+
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
138+
139+
expect(onSave).toHaveBeenCalledWith(Date.parse('2026-09-01T16:00:00Z') / 1000, 'enter')
140+
act(() => root.unmount())
141+
container.remove()
142+
})
143+
144+
it('rejects an impossible TTL draft without clearing the cell', () => {
145+
const container = document.createElement('div')
146+
document.body.appendChild(container)
147+
const root = createRoot(container)
148+
const onSave = vi.fn()
149+
150+
act(() =>
151+
root.render(
152+
createElement(InlineEditor, {
153+
value: Date.parse('2026-06-15T13:00:30Z') / 1000,
154+
column: column('ttl'),
155+
onSave,
156+
onCancel: vi.fn(),
157+
})
158+
)
159+
)
160+
161+
const input = container.querySelector('input') as HTMLInputElement
162+
act(() => changeInput(input, '02/30/2026 1:30 AM'))
163+
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
164+
165+
expect(onSave).not.toHaveBeenCalled()
166+
expect(mockToastError).toHaveBeenCalledWith('Invalid expiration date')
167+
act(() => root.unmount())
168+
container.remove()
169+
})
170+
171+
it.each([
172+
{ caseName: 'a historical sub-minute offset', timezone: 'Africa/Monrovia', value: 2670 },
173+
{
174+
caseName: 'the far-future representable boundary',
175+
timezone: 'Asia/Tokyo',
176+
value: 253_402_300_799,
177+
},
178+
])('preserves the exact epoch for $caseName when untouched', ({ timezone, value }) => {
179+
mockUseTimezoneState.mockReturnValue({ timezone, status: 'ready' })
180+
const container = document.createElement('div')
181+
document.body.appendChild(container)
182+
const root = createRoot(container)
183+
const onSave = vi.fn()
184+
185+
act(() =>
186+
root.render(
187+
createElement(InlineEditor, {
188+
value,
189+
column: column('ttl'),
190+
onSave,
191+
onCancel: vi.fn(),
192+
})
193+
)
194+
)
195+
196+
const input = container.querySelector('input') as HTMLInputElement
197+
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
198+
199+
expect(onSave).toHaveBeenCalledWith(value, 'enter')
200+
act(() => root.unmount())
201+
container.remove()
202+
})
203+
204+
it('cancels TTL editing when the saved timezone cannot be loaded', () => {
205+
mockUseTimezoneState.mockReturnValue({
206+
timezone: 'America/Los_Angeles',
207+
status: 'error',
208+
})
209+
const container = document.createElement('div')
210+
document.body.appendChild(container)
211+
const root = createRoot(container)
212+
const onCancel = vi.fn()
213+
214+
act(() =>
215+
root.render(
216+
createElement(InlineEditor, {
217+
value: 2670,
218+
column: column('ttl'),
219+
onSave: vi.fn(),
220+
onCancel,
221+
})
222+
)
223+
)
224+
225+
expect(onCancel).toHaveBeenCalledOnce()
226+
expect(mockToastError).toHaveBeenCalledWith('Could not load timezone')
227+
act(() => root.unmount())
228+
container.remove()
229+
})
89230
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { Check } from '@sim/emcn/icons'
1717
import type { ColumnDefinition } from '@/lib/table'
1818
import { columnTypeOf } from '@/lib/table/column-types'
1919
import { isCalendarDateString } from '@/lib/table/dates'
20-
import { useTimezone } from '@/hooks/queries/general-settings'
20+
import { useTimezoneState } from '@/hooks/queries/general-settings'
2121
import type { SaveReason } from '../../../types'
2222
import {
2323
cleanCellValue,
@@ -68,13 +68,46 @@ function handleEditorWheel(e: React.WheelEvent<HTMLInputElement>) {
6868
* edits update the draft in place — the day pick keeps the time-of-day
6969
* (including seconds), the time field keeps the day — and Enter/blur commits.
7070
*/
71-
function InlineDateEditor({
71+
function InlineDateEditor(props: InlineEditorProps) {
72+
const { column, onCancel } = props
73+
const timezoneState = useTimezoneState()
74+
const ttlTimezoneUnavailable = column.type === 'ttl' && timezoneState.status !== 'ready'
75+
76+
useEffect(() => {
77+
if (column.type !== 'ttl' || timezoneState.status !== 'error') return
78+
toast.error('Could not load timezone')
79+
onCancel()
80+
}, [column.type, onCancel, timezoneState.status])
81+
82+
if (ttlTimezoneUnavailable) {
83+
return (
84+
<input
85+
type='text'
86+
value=''
87+
disabled
88+
placeholder={
89+
timezoneState.status === 'error' ? 'Timezone unavailable' : 'Loading timezone...'
90+
}
91+
className='w-full min-w-0 border-none bg-transparent p-0 text-[var(--text-muted)] text-small outline-none'
92+
/>
93+
)
94+
}
95+
96+
return <ReadyInlineDateEditor {...props} initialTimeZone={timezoneState.timezone} />
97+
}
98+
99+
interface ReadyInlineDateEditorProps extends InlineEditorProps {
100+
initialTimeZone: string
101+
}
102+
103+
function ReadyInlineDateEditor({
72104
value,
73105
column,
74106
initialCharacter,
75107
onSave,
76108
onCancel,
77-
}: InlineEditorProps) {
109+
initialTimeZone,
110+
}: ReadyInlineDateEditorProps) {
78111
const inputRef = useRef<HTMLInputElement>(null)
79112
const popoverRef = useRef<HTMLDivElement>(null)
80113
const doneRef = useRef(false)
@@ -83,9 +116,8 @@ function InlineDateEditor({
83116
* and refocuses while a popover interaction is in flight (covers browsers
84117
* where buttons don't take focus on click). */
85118
const popoverPointerAtRef = useRef(0)
86-
const effectiveTimeZone = useTimezone()
87119
/** Keep one wall-clock interpretation for the lifetime of this edit. */
88-
const editTimeZoneRef = useRef(effectiveTimeZone)
120+
const editTimeZoneRef = useRef(initialTimeZone)
89121
const timeZone = editTimeZoneRef.current
90122

91123
const storedValue = formatValueForInput(value, column.type, timeZone)
@@ -133,26 +165,45 @@ function InlineDateEditor({
133165
// silently shifting the instant of a value someone else wrote.
134166
if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) {
135167
doneRef.current = true
136-
onSave(storedValue ? cleanCellValue(storedValue, column, timeZone) : null, reason)
168+
onSave(
169+
column.type === 'ttl'
170+
? (value ?? null)
171+
: storedValue
172+
? cleanCellValue(storedValue, column, timeZone)
173+
: null,
174+
reason
175+
)
137176
return
138177
}
139178
const raw = dateEditorRawValue(current, column, timeZone, storageVal)
140-
if (raw && Number.isNaN(Date.parse(raw))) {
179+
const cleaned = raw ? cleanCellValue(raw, column, timeZone) : null
180+
const parseError = columnTypeOf(column).parseErrorMessage
181+
if (raw && cleaned === null && parseError) {
141182
if (reason === 'blur') {
142-
if (!invalid) toast.error('Invalid date')
183+
if (!invalid) toast.error(parseError)
143184
doneRef.current = true
144185
onCancel()
145186
} else {
146-
toast.error('Invalid date')
187+
toast.error(parseError)
147188
setInvalid(true)
148189
inputRef.current?.focus()
149190
}
150191
return
151192
}
152193
doneRef.current = true
153-
onSave(raw ? cleanCellValue(raw, column, timeZone) : null, reason)
194+
onSave(cleaned, reason)
154195
},
155-
[invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue, column]
196+
[
197+
invalid,
198+
onSave,
199+
onCancel,
200+
timeZone,
201+
initialDraft,
202+
initialCharacter,
203+
storedValue,
204+
column,
205+
value,
206+
]
156207
)
157208

158209
const handleKeyDown = useCallback(

apps/sim/hooks/queries/general-settings.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ vi.mock('@tanstack/react-query', () => ({
1515
}))
1616
vi.mock('@/lib/core/utils/timezone', () => ({ getBrowserTimezone: mockGetBrowserTimezone }))
1717

18-
import { useTimezone } from '@/hooks/queries/general-settings'
18+
import { useTimezone, useTimezoneState } from '@/hooks/queries/general-settings'
1919

2020
describe('useTimezone', () => {
2121
beforeEach(() => {
@@ -27,6 +27,10 @@ describe('useTimezone', () => {
2727
mockUseQuery.mockReturnValue({ data: { timezone: null } })
2828

2929
expect(useTimezone()).toBe('America/Los_Angeles')
30+
expect(useTimezoneState()).toEqual({
31+
timezone: 'America/Los_Angeles',
32+
status: 'ready',
33+
})
3034
})
3135

3236
it('uses a saved timezone instead of the browser fallback', () => {
@@ -46,4 +50,22 @@ describe('useTimezone', () => {
4650
timezone = null
4751
expect(useTimezone()).toBe('America/Los_Angeles')
4852
})
53+
54+
it('distinguishes an unresolved preference from an explicit browser fallback', () => {
55+
mockUseQuery.mockReturnValue({ data: undefined, isError: false })
56+
57+
expect(useTimezoneState()).toEqual({
58+
timezone: 'America/Los_Angeles',
59+
status: 'loading',
60+
})
61+
})
62+
63+
it('reports an unavailable preference instead of treating it as resolved', () => {
64+
mockUseQuery.mockReturnValue({ data: undefined, isError: true })
65+
66+
expect(useTimezoneState()).toEqual({
67+
timezone: 'America/Los_Angeles',
68+
status: 'error',
69+
})
70+
})
4971
})

apps/sim/hooks/queries/general-settings.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,25 @@ export function useBillingUsageNotifications(): boolean {
149149
* captured so scheduling honors the account preference rather than the device.
150150
*/
151151
export function useTimezone(): string {
152-
const { data } = useGeneralSettings()
153-
return data?.timezone ?? getBrowserTimezone()
152+
return useTimezoneState().timezone
153+
}
154+
155+
export interface TimezoneState {
156+
timezone: string
157+
status: 'loading' | 'ready' | 'error'
158+
}
159+
160+
/**
161+
* The effective timezone together with whether the saved preference is known.
162+
* Destructive time-based editors use the status to avoid capturing the browser
163+
* fallback while the preference request is still in flight.
164+
*/
165+
export function useTimezoneState(): TimezoneState {
166+
const { data, isError } = useGeneralSettings()
167+
return {
168+
timezone: data?.timezone ?? getBrowserTimezone(),
169+
status: data ? 'ready' : isError ? 'error' : 'loading',
170+
}
154171
}
155172

156173
/**

0 commit comments

Comments
 (0)