Skip to content

Commit b09d5bf

Browse files
committed
fix(tables): guard row modal timezone edits
1 parent 7ab40b6 commit b09d5bf

2 files changed

Lines changed: 182 additions & 19 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, createElement, type ReactNode } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { TableInfo, TableRow } from '@/lib/table'
8+
import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal'
9+
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+
}))
16+
17+
vi.mock('next/navigation', () => ({
18+
useParams: () => ({ workspaceId: 'workspace-1' }),
19+
}))
20+
vi.mock('@/hooks/queries/general-settings', () => ({
21+
useTimezoneState: mockUseTimezoneState,
22+
}))
23+
vi.mock('@/hooks/queries/tables', () => ({
24+
useUpdateTableRow: () => ({ mutateAsync: mockUpdateRow, isPending: false }),
25+
useDeleteTableRow: () => ({ mutateAsync: mockDeleteRow, isPending: false }),
26+
useDeleteTableRows: () => ({ mutateAsync: mockDeleteRows, isPending: false }),
27+
}))
28+
vi.mock('@sim/emcn', () => {
29+
const passthrough = ({ children }: { children?: ReactNode }) => children ?? null
30+
return {
31+
Checkbox: () => null,
32+
ChipConfirmModal: passthrough,
33+
ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
34+
createElement(
35+
'button',
36+
{ type: 'button', 'data-testid': 'date', onClick: () => onChange(value ?? '2026-11-01') },
37+
value
38+
),
39+
ChipModal: passthrough,
40+
ChipModalBody: passthrough,
41+
ChipModalError: passthrough,
42+
ChipModalField: passthrough,
43+
ChipModalFooter: ({
44+
primaryAction,
45+
}: {
46+
primaryAction: { disabled?: boolean; onClick?: () => void }
47+
}) =>
48+
createElement(
49+
'button',
50+
{
51+
type: 'button',
52+
'data-testid': 'submit',
53+
disabled: primaryAction.disabled,
54+
onClick: primaryAction.onClick,
55+
},
56+
'Update Row'
57+
),
58+
ChipModalHeader: passthrough,
59+
ChipTimePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) =>
60+
createElement('input', {
61+
'data-testid': 'time',
62+
value: value ?? '',
63+
onChange: (event: { currentTarget: { value: string } }) =>
64+
onChange(event.currentTarget.value),
65+
}),
66+
Label: passthrough,
67+
}
68+
})
69+
70+
const table: TableInfo = {
71+
id: 'table-1',
72+
name: 'Expiring rows',
73+
schema: { columns: [{ name: 'expires_at', type: 'ttl' }] },
74+
}
75+
76+
const row: TableRow = {
77+
id: 'row-1',
78+
data: { expires_at: Date.parse('2026-11-01T08:00:00Z') / 1000 },
79+
executions: {},
80+
position: 0,
81+
createdAt: '2026-01-01T00:00:00Z',
82+
updatedAt: '2026-01-01T00:00:00Z',
83+
}
84+
85+
function changeInput(input: HTMLInputElement, value: string) {
86+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
87+
setter?.call(input, value)
88+
input.dispatchEvent(new Event('input', { bubbles: true }))
89+
}
90+
91+
describe('RowModal expiration editing', () => {
92+
beforeEach(() => {
93+
vi.clearAllMocks()
94+
mockUpdateRow.mockResolvedValue(undefined)
95+
})
96+
97+
it('waits for the saved timezone, freezes it, and chooses the later repeated hour', async () => {
98+
mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
99+
const container = document.createElement('div')
100+
document.body.appendChild(container)
101+
const root = createRoot(container)
102+
const props = {
103+
mode: 'edit' as const,
104+
isOpen: true,
105+
onClose: vi.fn(),
106+
table,
107+
row,
108+
onSuccess: vi.fn(),
109+
}
110+
111+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
112+
act(() => root.render(createElement(RowModal, props)))
113+
114+
expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…')
115+
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()
116+
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
117+
true
118+
)
119+
120+
mockUseTimezoneState.mockReturnValue({
121+
timezone: 'America/Los_Angeles',
122+
status: 'ready',
123+
})
124+
act(() => root.render(createElement(RowModal, props)))
125+
126+
mockUseTimezoneState.mockReturnValue({
127+
timezone: 'America/New_York',
128+
status: 'ready',
129+
})
130+
act(() => root.render(createElement(RowModal, props)))
131+
132+
const timeInput = container.querySelector<HTMLInputElement>('[data-testid="time"]')
133+
expect(timeInput?.value).toBe('01:00')
134+
act(() => changeInput(timeInput as HTMLInputElement, '01:30'))
135+
136+
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
137+
await act(async () => submit?.click())
138+
139+
expect(mockUpdateRow).toHaveBeenCalledWith({
140+
rowId: 'row-1',
141+
data: { expires_at: Date.parse('2026-11-01T09:30:00Z') / 1000 },
142+
})
143+
expect(props.onSuccess).toHaveBeenCalledTimes(1)
144+
145+
act(() => root.unmount())
146+
container.remove()
147+
})
148+
})

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

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useId, useState } from 'react'
3+
import { useId, useRef, useState } from 'react'
44
import {
55
Checkbox,
66
ChipConfirmModal,
@@ -20,7 +20,7 @@ import { useParams } from 'next/navigation'
2020
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
2121
import { columnTypeOf } from '@/lib/table/column-types'
2222
import { resolveCurrencyCode } from '@/lib/table/currency'
23-
import { useTimezone } from '@/hooks/queries/general-settings'
23+
import { useTimezoneState } from '@/hooks/queries/general-settings'
2424
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
2525
import {
2626
cleanCellValue,
@@ -78,7 +78,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
7878
const schema = table?.schema
7979
const columns = schema?.columns || []
8080

81-
const timeZone = useTimezone()
81+
const timezoneState = useTimezoneState()
82+
const editTimeZoneRef = useRef<string | null>(null)
83+
if (timezoneState.status === 'ready' && editTimeZoneRef.current === null) {
84+
editTimeZoneRef.current = timezoneState.timezone
85+
}
86+
const hasTtlColumn = mode === 'edit' && columns.some((column) => column.type === 'ttl')
87+
const ttlTimezoneUnavailable = hasTtlColumn && editTimeZoneRef.current === null
88+
const timeZone = editTimeZoneRef.current ?? timezoneState.timezone
8289
const [rowData, setRowData] = useState<Record<string, unknown>>(() =>
8390
mode === 'edit' && row ? row.data : {}
8491
)
@@ -92,6 +99,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
9299
const handleFormSubmit = async (e?: React.FormEvent) => {
93100
e?.preventDefault()
94101
setError(null)
102+
if (ttlTimezoneUnavailable) return
95103

96104
try {
97105
const cleanData = cleanRowData(columns, rowData, timeZone)
@@ -169,15 +177,22 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
169177
Update values for {table?.name ?? 'table'}
170178
</p>
171179
<form onSubmit={handleFormSubmit} className='contents'>
172-
<button type='submit' hidden disabled={isSubmitting} />
173-
{columns.map((column) => (
174-
<ColumnField
175-
key={column.name}
176-
column={column}
177-
value={rowData[column.name]}
178-
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
179-
/>
180-
))}
180+
<button type='submit' hidden disabled={isSubmitting || ttlTimezoneUnavailable} />
181+
{ttlTimezoneUnavailable ? (
182+
<p role='status' className='px-2 text-[var(--text-muted)] text-small'>
183+
{timezoneState.status === 'error' ? 'Timezone unavailable' : 'Loading timezone…'}
184+
</p>
185+
) : (
186+
columns.map((column) => (
187+
<ColumnField
188+
key={column.name}
189+
column={column}
190+
value={rowData[column.name]}
191+
timeZone={timeZone}
192+
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
193+
/>
194+
))
195+
)}
181196
</form>
182197
<ChipModalError>{error}</ChipModalError>
183198
</ChipModalBody>
@@ -187,7 +202,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
187202
primaryAction={{
188203
label: isSubmitting ? 'Updating...' : 'Update Row',
189204
onClick: () => handleFormSubmit(),
190-
disabled: isSubmitting,
205+
disabled: isSubmitting || ttlTimezoneUnavailable,
191206
}}
192207
/>
193208
</ChipModal>
@@ -197,12 +212,12 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
197212
interface ColumnFieldProps {
198213
column: ColumnDefinition
199214
value: unknown
215+
timeZone: string
200216
onChange: (value: unknown) => void
201217
}
202218

203-
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
219+
function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
204220
const checkboxId = useId()
205-
const timeZone = useTimezone()
206221
const title = (
207222
<>
208223
{column.name}
@@ -261,22 +276,22 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
261276

262277
if (definition.editor === 'date') {
263278
const parts = dateValueToLocalParts(formatValueForInput(value, column.type, timeZone))
279+
const valueFromParts = (day: string, time: string | null) =>
280+
column.type === 'ttl' && time ? `${day}T${time}` : localPartsToDateValue(day, time, timeZone)
264281
return (
265282
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
266283
<div className='flex items-center gap-2'>
267284
<ChipDatePicker
268285
value={parts.day ?? undefined}
269286
today={todayLocalCalendarDate(timeZone)}
270-
onChange={(day) => onChange(localPartsToDateValue(day, parts.time, timeZone))}
287+
onChange={(day) => onChange(valueFromParts(day, parts.time))}
271288
placeholder='Select date'
272289
className='flex-1'
273290
/>
274291
<ChipTimePicker
275292
value={parts.time?.slice(0, 5)}
276293
onChange={(time) =>
277-
onChange(
278-
localPartsToDateValue(parts.day ?? todayLocalCalendarDate(timeZone), time, timeZone)
279-
)
294+
onChange(valueFromParts(parts.day ?? todayLocalCalendarDate(timeZone), time))
280295
}
281296
placeholder='Add time'
282297
className='w-[110px]'

0 commit comments

Comments
 (0)