@@ -11,9 +11,12 @@ import {
1111} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors'
1212import { 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 } ) )
1720vi . 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} )
3235const 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+
3443describe ( '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} )
0 commit comments