diff --git a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts index 8b31afd352e..9e62e4eb1ce 100644 --- a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts +++ b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts @@ -77,6 +77,23 @@ describe('table row TTL cleanup route', () => { expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId) }) + it('uses a new id immediately after the next fifteen-minute window begins', async () => { + const request = () => + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + + vi.setSystemTime(new Date('2026-08-22T17:14:59.999Z')) + await GET(request()) + vi.setSystemTime(new Date('2026-08-22T17:15:00.000Z')) + await GET(request()) + + expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).not.toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId) + }) + it('returns the cron auth refusal without touching the queue', async () => { mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 })) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx new file mode 100644 index 00000000000..851a755b22a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx @@ -0,0 +1,148 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableInfo, TableRow } from '@/lib/table' +import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal' + +const { mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = vi.hoisted(() => ({ + mockUseTimezoneState: vi.fn(), + mockUpdateRow: vi.fn(), + mockDeleteRow: vi.fn(), + mockDeleteRows: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), +})) +vi.mock('@/hooks/queries/general-settings', () => ({ + useTimezoneState: mockUseTimezoneState, +})) +vi.mock('@/hooks/queries/tables', () => ({ + useUpdateTableRow: () => ({ mutateAsync: mockUpdateRow, isPending: false }), + useDeleteTableRow: () => ({ mutateAsync: mockDeleteRow, isPending: false }), + useDeleteTableRows: () => ({ mutateAsync: mockDeleteRows, isPending: false }), +})) +vi.mock('@sim/emcn', () => { + const passthrough = ({ children }: { children?: ReactNode }) => children ?? null + return { + Checkbox: () => null, + ChipConfirmModal: passthrough, + ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) => + createElement( + 'button', + { type: 'button', 'data-testid': 'date', onClick: () => onChange(value ?? '2026-11-01') }, + value + ), + ChipModal: passthrough, + ChipModalBody: passthrough, + ChipModalError: passthrough, + ChipModalField: passthrough, + ChipModalFooter: ({ + primaryAction, + }: { + primaryAction: { disabled?: boolean; onClick?: () => void } + }) => + createElement( + 'button', + { + type: 'button', + 'data-testid': 'submit', + disabled: primaryAction.disabled, + onClick: primaryAction.onClick, + }, + 'Update Row' + ), + ChipModalHeader: passthrough, + ChipTimePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) => + createElement('input', { + 'data-testid': 'time', + value: value ?? '', + onChange: (event: { currentTarget: { value: string } }) => + onChange(event.currentTarget.value), + }), + Label: passthrough, + } +}) + +const table: TableInfo = { + id: 'table-1', + name: 'Expiring rows', + schema: { columns: [{ name: 'expires_at', type: 'ttl' }] }, +} + +const row: TableRow = { + id: 'row-1', + data: { expires_at: Date.parse('2026-11-01T08:00:00Z') / 1000 }, + executions: {}, + position: 0, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +} + +function changeInput(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +describe('RowModal expiration editing', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateRow.mockResolvedValue(undefined) + }) + + it('waits for the saved timezone, freezes it, and chooses the later repeated hour', async () => { + mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'edit' as const, + isOpen: true, + onClose: vi.fn(), + table, + row, + onSuccess: vi.fn(), + } + + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + act(() => root.render(createElement(RowModal, props))) + + expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…') + expect(container.querySelector('[data-testid="time"]')).toBeNull() + expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe( + true + ) + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + act(() => root.render(createElement(RowModal, props))) + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/New_York', + status: 'ready', + }) + act(() => root.render(createElement(RowModal, props))) + + const timeInput = container.querySelector('[data-testid="time"]') + expect(timeInput?.value).toBe('01:00') + act(() => changeInput(timeInput as HTMLInputElement, '01:30')) + + const submit = container.querySelector('[data-testid="submit"]') + await act(async () => submit?.click()) + + expect(mockUpdateRow).toHaveBeenCalledWith({ + rowId: 'row-1', + data: { expires_at: Date.parse('2026-11-01T09:30:00Z') / 1000 }, + }) + expect(props.onSuccess).toHaveBeenCalledTimes(1) + + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index e9734bafba0..e139b38b849 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useId, useState } from 'react' +import { useId, useRef, useState } from 'react' import { Checkbox, ChipConfirmModal, @@ -20,7 +20,7 @@ import { useParams } from 'next/navigation' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' import { columnTypeOf } from '@/lib/table/column-types' import { resolveCurrencyCode } from '@/lib/table/currency' -import { useTimezone } from '@/hooks/queries/general-settings' +import { useTimezoneState } from '@/hooks/queries/general-settings' import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables' import { cleanCellValue, @@ -78,7 +78,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const schema = table?.schema const columns = schema?.columns || [] - const timeZone = useTimezone() + const timezoneState = useTimezoneState() + const editTimeZoneRef = useRef(null) + if (timezoneState.status === 'ready' && editTimeZoneRef.current === null) { + editTimeZoneRef.current = timezoneState.timezone + } + const hasTtlColumn = mode === 'edit' && columns.some((column) => column.type === 'ttl') + const ttlTimezoneUnavailable = hasTtlColumn && editTimeZoneRef.current === null + const timeZone = editTimeZoneRef.current ?? timezoneState.timezone const [rowData, setRowData] = useState>(() => mode === 'edit' && row ? row.data : {} ) @@ -92,6 +99,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const handleFormSubmit = async (e?: React.FormEvent) => { e?.preventDefault() setError(null) + if (ttlTimezoneUnavailable) return try { const cleanData = cleanRowData(columns, rowData, timeZone) @@ -169,15 +177,22 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess Update values for {table?.name ?? 'table'}

-