@@ -7,12 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
77import type { TableInfo , TableRow } from '@/lib/table'
88import { 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
1719vi . 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