Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,30 @@ describe('table row TTL cleanup route', () => {
})
expect(mockGetJobQueue).not.toHaveBeenCalled()
})

it.each(['initialization', 'enqueue'])(
'reports a queue %s failure and permits a later retry',
async (stage) => {
if (stage === 'initialization') {
mockGetJobQueue.mockRejectedValueOnce(new Error('queue unavailable'))
} else {
mockEnqueue.mockRejectedValueOnce(new Error('connection lost'))
}
const request = () =>
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
)
const failed = await GET(request())
expect(failed.status).toBe(500)
await expect(failed.json()).resolves.toEqual({
error: 'Failed to dispatch table row TTL cleanup',
})
const retried = await GET(request())
expect(retried.status).toBe(200)
await expect(retried.json()).resolves.toEqual({ triggered: true, jobId: 'job-ttl-1' })
}
)
})
29 changes: 28 additions & 1 deletion apps/sim/app/api/table/[tableId]/columns/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ vi.mock('@/lib/table/wire', () => ({
vi.mock('@/app/api/table/utils', () => ({
accessError: () => new Response('denied', { status: 403 }),
checkAccess: mockCheckAccess,
orchestrationErrorResponse: (error: unknown) =>
error instanceof OrchestrationError
? NextResponse.json(
{ error: error.message },
{ status: statusForOrchestrationError(error.code) }
)
: null,
orchestrationOutcomeErrorResponse: (
outcome: { error?: string; errorCode?: OrchestrationErrorCode },
fallback: string
Expand All @@ -73,7 +80,7 @@ import {
type OrchestrationErrorCode,
statusForOrchestrationError,
} from '@/lib/core/orchestration/types'
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
import { PATCH, POST } from '@/app/api/table/[tableId]/columns/route'

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'

Expand Down Expand Up @@ -106,6 +113,26 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
mockRenameColumn.mockResolvedValue({ schema: { columns: [] } })
})

it.each([
'Schema validation failed: A table can have at most 1 Expiration column',
'Expiration columns are not enabled',
])('returns a validation response when adding a column fails: %s', async (message) => {
mockAddTableColumn.mockRejectedValueOnce(new OrchestrationError('validation', message))
const response = await POST(
new NextRequest('http://localhost/api/table/t1/columns', {
method: 'POST',
body: JSON.stringify({
workspaceId: WORKSPACE_ID,
column: { name: 'expires', type: 'ttl' },
}),
headers: { 'content-type': 'application/json' },
}),
{ params: Promise.resolve({ tableId: 't1' }) }
)
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: message })
})

it('rejects a currency code on a non-currency column without renaming first', async () => {
const response = await patch({ name: 'renamed', currencyCode: 'USD' })

Expand Down
5 changes: 3 additions & 2 deletions apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { normalizeColumn } from '@/lib/table/wire'
import {
accessError,
checkAccess,
orchestrationErrorResponse,
orchestrationOutcomeErrorResponse,
rootErrorMessage,
tableLockErrorResponse,
Expand Down Expand Up @@ -63,8 +64,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
const classifiedError = orchestrationErrorResponse(error)
if (classifiedError) return classifiedError
if (isZodError(error)) {
return validationErrorResponse(error, 'Invalid request data')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const table: TableInfo = {

const row: TableRow = {
id: 'row-1',
data: { expires_at: Date.parse('2026-11-01T08:00:00Z') / 1000 },
data: { expires_at: '2026-11-01T01:00:00-07:00' },
executions: {},
position: 0,
createdAt: '2026-01-01T00:00:00Z',
Expand All @@ -119,7 +119,7 @@ describe('RowModal expiration editing', () => {
mockUpdateRow.mockResolvedValue(undefined)
})

it('waits for the saved timezone, freezes it, and chooses the later repeated hour', async () => {
it('preserves expiration offsets while timezone settings load or change', async () => {
mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' })
const container = document.createElement('div')
document.body.appendChild(container)
Expand All @@ -136,12 +136,9 @@ describe('RowModal expiration editing', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
act(() => root.render(createElement(RowModal, props)))

expect(container.querySelector('[aria-label="Edit expires_at"]')?.textContent).toBe(
'Loading timezone…'
)
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')).toBeNull()
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
true
false
)

mockUseTimezoneState.mockReturnValue({
Expand All @@ -165,7 +162,7 @@ describe('RowModal expiration editing', () => {

expect(mockUpdateRow).toHaveBeenCalledWith({
rowId: 'row-1',
data: { expires_at: Date.parse('2026-11-01T09:30:00Z') / 1000 },
data: { expires_at: '2026-11-01T01:30:00-07:00' },
})
expect(props.onSuccess).toHaveBeenCalledTimes(1)

Expand Down Expand Up @@ -209,7 +206,7 @@ describe('RowModal expiration editing', () => {
container.remove()
})

it('blocks an invalid saved timezone with the plain-text guidance', () => {
it('allows expiration edits even when the saved timezone is invalid', () => {
mockUseTimezoneState.mockReturnValue({
timezone: 'America/Los_Angeles',
savedTimezone: 'Mars/Olympus',
Expand All @@ -229,18 +226,11 @@ describe('RowModal expiration editing', () => {

act(() => root.render(createElement(RowModal, props)))

const blockedField = container.querySelector<HTMLButtonElement>(
'[aria-label="Edit expires_at"]'
)
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
expect(container.querySelector<HTMLInputElement>('[data-testid="time"]')?.value).toBe('01:00')
expect(container.querySelector<HTMLButtonElement>('[data-testid="submit"]')?.disabled).toBe(
true
false
)
expect(mockToastError).not.toHaveBeenCalled()
act(() => blockedField?.click())
expect(mockToastError).toHaveBeenCalledWith(
'Your saved timezone “Mars/Olympus” is invalid. Update it in Settings → General before editing Date or Expiration cells.'
)
act(() => root.unmount())
container.remove()
})
Expand All @@ -259,11 +249,19 @@ describe('RowModal expiration editing', () => {
schema: {
columns: [
{ name: 'name', type: 'string' },
{ name: 'starts_at', type: 'date' },
{ name: 'expires_at', type: 'ttl' },
],
},
}
const mixedRow = { ...row, data: { name: 'Ada', expires_at: row.data.expires_at } }
const mixedRow = {
...row,
data: {
name: 'Ada',
expires_at: row.data.expires_at,
starts_at: '2026-09-07T12:00:00-07:00',
},
}
const props = {
mode: 'edit' as const,
isOpen: true,
Expand All @@ -276,20 +274,18 @@ describe('RowModal expiration editing', () => {
act(() => root.render(createElement(RowModal, props)))

const nameInput = container.querySelector<HTMLInputElement>('[data-testid="modal-input"]')
const blockedField = container.querySelector<HTMLButtonElement>(
'[aria-label="Edit expires_at"]'
)
const blockedField = container.querySelector<HTMLButtonElement>('[aria-label="Edit starts_at"]')
const submit = container.querySelector<HTMLButtonElement>('[data-testid="submit"]')
expect(nameInput?.value).toBe('Ada')
expect(blockedField?.textContent).toBe(String(row.data.expires_at))
expect(blockedField?.textContent).toBe(mixedRow.data.starts_at)
expect(submit?.disabled).toBe(false)

act(() => changeInput(nameInput as HTMLInputElement, 'Grace'))
await act(async () => submit?.click())

expect(mockUpdateRow).toHaveBeenCalledWith({
rowId: 'row-1',
data: { name: 'Grace' },
data: { name: 'Grace', expires_at: row.data.expires_at },
})
expect(props.onSuccess).toHaveBeenCalledTimes(1)
expect(mockToastError).not.toHaveBeenCalled()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 { todayAtTtlOffset, ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
import { type TimezoneState, useTimezoneState } from '@/hooks/queries/general-settings'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
Expand Down Expand Up @@ -332,36 +333,45 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
required={column.required}
hint={hint}
mono
value={formatValueForInput(value, column.type, timeZone)}
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder='{"key": "value"}'
rows={4}
/>
)
}

if (definition.editor === 'date') {
const parts = dateValueToLocalParts(formatValueForInput(value, column.type, timeZone))
if (definition.editor === 'date' || definition.editor === 'offset-date') {
const storedValue = formatValueForInput(value, column.type)
const offsetParts =
definition.editor === 'offset-date' ? ttlValueToPickerParts(storedValue) : null
const parts = offsetParts ?? dateValueToLocalParts(storedValue)
const pickerToday = offsetParts
? todayAtTtlOffset(offsetParts.offset)
: todayLocalCalendarDate(timeZone)
const valueFromParts = (day: string, time: string | null) =>
column.type === 'ttl' && time ? `${day}T${time}` : localPartsToDateValue(day, time, timeZone)
offsetParts
? ttlValueFromPicker(day, time, offsetParts.offset)
: localPartsToDateValue(day, time, timeZone)
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<div className='flex items-center gap-2'>
<ChipDatePicker
value={parts.day ?? undefined}
today={todayLocalCalendarDate(timeZone)}
today={pickerToday}
onChange={(day) => onChange(valueFromParts(day, parts.time))}
placeholder='Select date'
className='flex-1'
/>
<ChipTimePicker
value={parts.time?.slice(0, 5)}
onChange={(time) =>
onChange(valueFromParts(parts.day ?? todayLocalCalendarDate(timeZone), time))
}
onChange={(time) => onChange(valueFromParts(parts.day ?? pickerToday, time))}
placeholder='Add time'
className='w-[110px]'
/>
{offsetParts && (
<span className='text-[var(--text-tertiary)] text-small'>{offsetParts.offset}</span>
)}
</div>
</ChipModalField>
)
Expand All @@ -387,7 +397,7 @@ function ColumnField({ column, value, timeZone, onChange }: ColumnFieldProps) {
inputType={
definition.inputMode === 'decimal' && !definition.acceptsFormattedInput ? 'number' : 'text'
}
value={formatValueForInput(value, column.type, timeZone)}
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder={`Enter ${column.name}`}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ interface CellContentProps {
/** Current workspace id — lets string cells holding an in-workspace resource
* URL render as a tagged-resource chip instead of a plain external link. */
workspaceId: string
timeZone: string
timezoneStatus: TimezoneState['status']
isEditing: boolean
initialCharacter?: string | null
Expand All @@ -41,7 +40,6 @@ export function CellContent({
exec,
column,
workspaceId,
timeZone,
timezoneStatus,
isEditing,
initialCharacter,
Expand All @@ -57,7 +55,6 @@ export function CellContent({
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
timeZone,
timezoneStatus,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,43 +23,23 @@ function column(type: DisplayColumn['type']): DisplayColumn {
}

describe('resolveCellRender', () => {
it('renders TTL epoch seconds through the date presentation', () => {
expect(
resolveCellRender({
value: 1_700_000_000,
it.each(['ready', 'loading', 'invalid', 'error'] as const)(
'renders TTL as the exact UTC string when timezone status is %s',
(timezoneStatus) => {
const value = '2026-06-15T09:00:30Z'
const kind = resolveCellRender({
value,
exec: undefined,
column: column('ttl'),
waitingOnLabels: undefined,
timeZone: 'America/New_York',
timezoneStatus,
})
).toEqual({ kind: 'date', text: '2023-11-14T17:13:20-05:00' })
})

it('renders raw epoch seconds when the saved timezone is invalid', () => {
expect(
resolveCellRender({
value: 1_700_000_000,
exec: undefined,
column: column('ttl'),
waitingOnLabels: undefined,
timeZone: 'America/Los_Angeles',
timezoneStatus: 'invalid',
})
).toEqual({ kind: 'date', text: '1700000000', raw: true })
})

it('renders raw epoch seconds while timezone settings are loading', () => {
expect(
resolveCellRender({
value: 1_700_000_000,
exec: undefined,
column: column('ttl'),
waitingOnLabels: undefined,
timeZone: 'America/Los_Angeles',
timezoneStatus: 'loading',
})
).toEqual({ kind: 'date', text: '1700000000', raw: true })
})
expect(kind).toEqual({ kind: 'text', text: value })
expect(renderToStaticMarkup(createElement(CellRender, { kind, isEditing: false }))).toContain(
value
)
}
)

it('renders the exact stored Date value when timezone settings are unavailable', () => {
const stored = '2026-01-15T09:00:00-05:00'
Expand All @@ -68,7 +48,6 @@ describe('resolveCellRender', () => {
exec: undefined,
column: column('date'),
waitingOnLabels: undefined,
timeZone: 'America/Los_Angeles',
timezoneStatus: 'error',
})
expect(kind).toEqual({ kind: 'date', text: stored, raw: true })
Expand Down
Loading
Loading