Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/sim/app/api/table/[tableId]/import-async/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
import { getUserSettings } from '@/lib/users/queries'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableImportIntoAsync')
Expand All @@ -33,7 +34,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
const parsed = await parseRequest(importIntoTableAsyncContract, request, { params })
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const { workspaceId, fileKey, fileName, mode, mapping, createColumns } = parsed.data.body
const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } =
parsed.data.body

const access = await checkAccess(tableId, userId, 'write')
if (!access.ok) return accessError(access, requestId, tableId)
Expand Down Expand Up @@ -79,6 +81,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
mode,
mapping,
createColumns,
timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC',
}
if (isTriggerDevEnabled) {
// Trigger.dev runs the import outside the web container, so it survives app deploys.
Expand Down
18 changes: 17 additions & 1 deletion apps/sim/app/api/table/[tableId]/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
csvImportModeSchema,
tableIdParamsSchema,
} from '@/lib/api/contracts/tables'
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
Expand All @@ -36,6 +37,7 @@ import {
wouldExceedRowLimit,
} from '@/lib/table'
import { importAppendRows, importReplaceRows } from '@/lib/table/import-data'
import { getUserSettings } from '@/lib/users/queries'
import {
accessError,
checkAccess,
Expand Down Expand Up @@ -162,6 +164,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
createColumns = createColumnsValidation.data
}

let timezone = (await getUserSettings(authResult.userId)).timezone ?? 'UTC'
if (fields.timezone) {
const timezoneValidation = ianaTimezoneSchema.safeParse(fields.timezone)
if (!timezoneValidation.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(timezoneValidation.error) },
{ status: 400 }
)
}
timezone = timezoneValidation.data
}

const delimiter = extensionValidation.data === 'tsv' ? '\t' : ','
const parser = createCsvParser(delimiter)
// `.pipe` doesn't forward source errors; forward them so the iterator throws.
Expand Down Expand Up @@ -250,7 +264,9 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
)
}

const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap)
const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap, {
timezone,
})

// Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot
// taken before the parse/validation; a background import could claim the table in that window.
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/table/import-async/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
TableConflictError,
} from '@/lib/table'
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
import { getUserSettings } from '@/lib/users/queries'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('TableImportAsync')
Expand All @@ -38,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

const parsed = await parseRequest(importTableAsyncContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId, fileKey, fileName, deleteSourceFile } = parsed.data.body
const { workspaceId, fileKey, fileName, deleteSourceFile, timezone } = parsed.data.body

const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
Expand Down Expand Up @@ -111,6 +112,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
delimiter,
mode: 'create',
deleteSourceFile,
timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC',
}
if (isTriggerDevEnabled) {
// Trigger.dev runs the import outside the web container, so it survives app deploys.
Expand Down
16 changes: 15 additions & 1 deletion apps/sim/app/api/table/import-csv/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables'
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
Expand All @@ -25,6 +26,7 @@ import {
type TableDefinition,
type TableSchema,
} from '@/lib/table'
import { getUserSettings } from '@/lib/users/queries'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import {
csvProxyBodyCapResponse,
Expand Down Expand Up @@ -85,6 +87,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}

let timezone = (await getUserSettings(userId)).timezone ?? 'UTC'
if (fields.timezone) {
const timezoneResult = ianaTimezoneSchema.safeParse(fields.timezone)
if (!timezoneResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(timezoneResult.error) },
{ status: 400 }
)
}
timezone = timezoneResult.data
}

const ext = file.filename.split('.').pop()?.toLowerCase()
const extensionResult = csvExtensionSchema.safeParse(ext)
if (!extensionResult.success) {
Expand Down Expand Up @@ -112,7 +126,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
currentRowCount: number
) => {
if (rows.length === 0) return 0
const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn)
const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn, { timezone })
const result = await batchInsertRows(
{ tableId: state.table.id, rows: coerced, workspaceId, userId },
// The created table's rowCount is frozen at 0; pass the running total so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipTimePicker,
Label,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { useTimezone } from '@/hooks/queries/general-settings'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
import { cleanCellValue, formatValueForInput } from '../../utils'
import {
cleanCellValue,
dateValueToLocalParts,
formatValueForInput,
localPartsToDateValue,
todayLocalCalendarDate,
} from '../../utils'

const logger = createLogger('RowModal')

Expand All @@ -34,14 +42,15 @@ export interface RowModalProps {

function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>
rowData: Record<string, unknown>,
timeZone: string
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}

columns.forEach((col) => {
const value = rowData[col.name]
try {
cleanData[col.name] = cleanCellValue(value, col)
cleanData[col.name] = cleanCellValue(value, col, timeZone)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
Expand All @@ -66,6 +75,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
const schema = table?.schema
const columns = schema?.columns || []

const timeZone = useTimezone()
const [rowData, setRowData] = useState<Record<string, unknown>>(() =>
mode === 'edit' && row ? row.data : {}
)
Expand All @@ -81,7 +91,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
setError(null)

try {
const cleanData = cleanRowData(columns, rowData)
const cleanData = cleanRowData(columns, rowData, timeZone)

if (row) {
await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData })
Expand Down Expand Up @@ -189,6 +199,7 @@ interface ColumnFieldProps {

function ColumnField({ column, value, onChange }: ColumnFieldProps) {
const checkboxId = useId()
const timeZone = useTimezone()
const title = (
<>
{column.name}
Expand Down Expand Up @@ -236,14 +247,29 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
}

if (column.type === 'date') {
const parts = dateValueToLocalParts(formatValueForInput(value, 'date'), timeZone)
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<ChipDatePicker
value={formatValueForInput(value, column.type) || undefined}
onChange={onChange}
placeholder='Select date'
fullWidth
/>
<div className='flex items-center gap-2'>
<ChipDatePicker
value={parts.day ?? undefined}
onChange={(day) => onChange(localPartsToDateValue(day, parts.time, timeZone))}
placeholder='Select date'
flush
className='flex-1'
/>
<ChipTimePicker
value={parts.time?.slice(0, 5)}
onChange={(time) =>
onChange(
localPartsToDateValue(parts.day ?? todayLocalCalendarDate(timeZone), time, timeZone)
)
}
placeholder='Add time'
flush
className='w-[110px]'
/>
</div>
</ChipModalField>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
import { parse } from 'tldts'
import type { RowExecutionMetadata } from '@/lib/table'
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
import { useTimezone } from '@/hooks/queries/general-settings'
import { storageToDisplay } from '../../../utils'
import type { DisplayColumn } from '../types'
import { SimResourceCell, type SimResourceType } from './sim-resource-cell'
Expand Down Expand Up @@ -234,6 +235,7 @@ interface CellRenderProps {
export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null {
const valueText = kind.kind === 'value' ? kind.text : null
const revealedValueText = useTypewriter(valueText)
const timeZone = useTimezone()

switch (kind.kind) {
case 'value':
Expand Down Expand Up @@ -332,7 +334,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
case 'date':
return (
<span className={cn('text-[var(--text-primary)]', isEditing && 'invisible')}>
{storageToDisplay(kind.text)}
{storageToDisplay(kind.text, { seconds: true, timeZone })}
</span>
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ import type React from 'react'
import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@sim/emcn'
import type { TableRow as TableRowType } from '@/lib/table'
import { useTimezone } from '@/hooks/queries/general-settings'
import type { EditingCell, SaveReason } from '../../../types'
import { cleanCellValue, displayToStorage, formatValueForInput } from '../../../utils'
import {
cleanCellValue,
displayToStorage,
formatValueForInput,
storageToDisplay,
} from '../../../utils'
import type { DisplayColumn } from '../types'

interface ExpandedCellPopoverProps {
Expand Down Expand Up @@ -40,6 +46,7 @@ export function ExpandedCellPopover({
const rootRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null)
const timeZone = useTimezone()

const target = useMemo(() => {
if (!expandedCell) return null
Expand Down Expand Up @@ -145,7 +152,14 @@ export function ExpandedCellPopover({
{isEditable ? (
<ExpandedCellEditor
key={`${expandedCell.rowId}:${expandedCell.columnKey ?? expandedCell.columnName}`}
initialValue={formatValueForInput(target.value, target.column.type)}
initialValue={
target.column.type === 'date'
? storageToDisplay(formatValueForInput(target.value, 'date'), {
seconds: true,
timeZone,
})
: formatValueForInput(target.value, target.column.type)
}
column={target.column}
rowId={target.row.id}
onSave={onSave}
Expand Down Expand Up @@ -198,14 +212,16 @@ function ExpandedCellEditor({
}: ExpandedCellEditorProps) {
const [draftValue, setDraftValue] = useState(initialValue)
const [parseError, setParseError] = useState<string | null>(null)
const timeZone = useTimezone()

const handleSave = () => {
// `displayToStorage` only normalizes dates — it returns null for anything else.
// Fall back to the raw draft for non-date columns, matching the inline editor.
const raw = displayToStorage(draftValue) ?? draftValue
// Only date columns go through `displayToStorage` — it now parses many
// date shapes, so a number draft like "2024" must not reach it.
const raw =
column.type === 'date' ? (displayToStorage(draftValue, timeZone) ?? draftValue) : draftValue
let cleaned: unknown
try {
cleaned = cleanCellValue(raw, column)
cleaned = cleanCellValue(raw, column, timeZone)
} catch {
setParseError('Invalid JSON')
return
Expand Down
Loading
Loading