Skip to content

Commit fc49150

Browse files
committed
feat(tables): add email, phone, url, percent, duration column types
Five types on the existing registry, plus the two metadata keys they need. email / phone / url store text and normalize on write — case-folded addresses (enrichment cascades key on them), E.164-stripped numbers, absolute http(s) URLs. url restricts to http/https because the grid renders it as an anchor, so a javascript: value would otherwise sit behind a link the next viewer clicks. percent and duration store bare numbers so filters and sorts stay numeric: "> 50%" and ">= 1h" are plain numeric ranges rather than string comparisons. percent stores the number as shown (25, not 0.25) so converting to and from `number` rewrites no cells. `precision` (number + percent) replaces `number`'s bare String(value), which rendered a computed 0.30000000000000004 raw. `includeTime` (date) stops a date-only column silently acquiring a time from a paste or import; only an explicit false truncates, so columns predating the key keep their instants while new ones are date-only. Two registry-driven changes fall out. `cell-render.tsx` no longer branches on `column.type` — types declare a `display` kind, which also moves the "renders even when empty" decision (boolean, select) out of an unexplained ordering dependency around a shared isNull early-return. And `import.ts`'s coerceValue, a second write path whose `default` arm String()-ed everything, now falls back to the registry: a text-cast column still keeps the raw string so the row error can name it, but a numeric/timestamptz column nulls instead of storing text that makes every later query on the column error. Metadata ownership is declared once in METADATA_KEY_OWNERS. Each type's ownedMetadata derives from it, and the API contract reads the same map — it is client-reachable and so cannot import the icon-carrying registry.
1 parent 78cf534 commit fc49150

29 files changed

Lines changed: 925 additions & 94 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { parse } from 'tldts'
77
import { faviconUrl } from '@/lib/core/utils/favicon'
88
import type { RowExecutionMetadata, SelectOption } from '@/lib/table'
99
import { columnTypeOf } from '@/lib/table/column-types'
10+
import type { ColumnCellDisplay } from '@/lib/table/column-types/types'
1011
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
1112
import { storageToDisplay } from '../../../utils'
1213
import { resolveSelectOptions, SelectPill } from '../../select-field'
@@ -122,27 +123,37 @@ export function resolveCellRender({
122123
return { kind: 'empty' }
123124
}
124125

125-
if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) }
126-
// Always render select cells as the `select` kind — an empty one shows a muted
127-
// "None" so every select cell reads as a clickable dropdown.
128-
if (column.type === 'select') {
129-
return { kind: 'select', options: resolveSelectOptions(column, value) }
126+
// Every plain typed cell: the column's type says WHAT to draw, this switch
127+
// says how. Adding a type therefore adds no branch here — which is the rule
128+
// the previous chain of `column.type === …` tests broke.
129+
const definition = columnTypeOf(column)
130+
const cell: ColumnCellDisplay = definition.display?.(value, column) ?? {
131+
kind: isNull ? 'empty' : 'text',
132+
text: isNull ? '' : definition.formatForDisplay(value, column),
130133
}
131-
if (isNull) return { kind: 'empty' }
132-
// Formatted here rather than in a render branch because the symbol and
133-
// fraction digits come from the COLUMN's currency, which the render switch
134-
// (keyed on kind alone) no longer has. Renders as plain text — a currency
135-
// cell is a number cell with a symbol, so it stays left-aligned like one.
136-
if (column.type === 'currency') {
137-
return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) }
138-
}
139-
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
140-
if (column.type === 'date') return { kind: 'date', text: String(value) }
141-
if (column.type === 'string') {
142-
const text = stringifyValue(value)
143-
return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text }
134+
135+
switch (cell.kind) {
136+
case 'boolean':
137+
return { kind: 'boolean', checked: cell.checked }
138+
case 'select':
139+
return { kind: 'select', options: resolveSelectOptions(column, value) }
140+
case 'json':
141+
return { kind: 'json', text: cell.text }
142+
case 'date':
143+
return { kind: 'date', text: cell.text }
144+
case 'linkable':
145+
// Promotion needs the current workspace id, which is request context the
146+
// registry deliberately does not hold.
147+
return resolveLinkKind(cell.text, currentWorkspaceId) ?? { kind: 'text', text: cell.text }
148+
case 'empty':
149+
return { kind: 'empty' }
150+
case 'text':
151+
return { kind: 'text', text: cell.text }
152+
default: {
153+
const _exhaustive: never = cell
154+
return _exhaustive
155+
}
144156
}
145-
return { kind: 'text', text: stringifyValue(value) }
146157
}
147158

148159
function stringifyValue(value: unknown): string {

apps/sim/lib/api/contracts/tables.ts

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
TableRowsCursor,
2525
TableViewConfig,
2626
} from '@/lib/table'
27+
import { METADATA_KEY_OWNERS, TYPE_SPECIFIC_COLUMN_KEYS } from '@/lib/table/column-types/types'
2728
import {
2829
COLUMN_TYPES,
2930
FILTER_OPS,
@@ -33,6 +34,7 @@ import {
3334
TABLE_LIMITS,
3435
} from '@/lib/table/constants'
3536
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
37+
import type { ColumnTypeMetadata } from '@/lib/table/types'
3638

3739
export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
3840

@@ -64,61 +66,67 @@ export const selectOptionsSchema = z
6466
* table would make any divergence between the two runtimes' currency lists
6567
* reject an entire table schema over one column's code.
6668
*/
69+
/**
70+
* Decimal places for a `number` / `percent` column.
71+
*
72+
* Bounded here as well as server-side because this schema parses RESPONSES
73+
* too — an out-of-range value stored by an older client would otherwise reject
74+
* the whole table schema on read.
75+
*/
76+
export const precisionSchema = z
77+
.number()
78+
.int('precision must be a whole number of decimal places')
79+
.min(0, 'precision cannot be negative')
80+
.max(10, 'precision cannot exceed 10 decimal places')
81+
6782
export const currencyCodeSchema = z
6883
.string()
6984
.regex(/^[A-Za-z]{3}$/, 'Must be a 3-letter ISO 4217 currency code, e.g. USD')
7085
.transform((code) => code.toUpperCase())
7186

7287
/**
73-
* Cross-field rule: a `select` column must declare a non-empty option set;
74-
* other types must not carry options or `multiple`, and only a `currency`
75-
* column may carry `currencyCode`. Skipped when `type` is absent (a
88+
* Cross-field rule: a `select` column must declare a non-empty option set, and
89+
* no column may carry a type-specific key its type does not own. Ownership is
90+
* read from `METADATA_KEY_OWNERS`, the same map the server enforces, so this
91+
* needs no edit as keys are added. Skipped when `type` is absent (a
7692
* metadata-only update on an existing column).
7793
*/
7894
function refineColumnOptions(
7995
data: {
8096
type?: (typeof COLUMN_TYPES)[number]
8197
options?: z.infer<typeof selectOptionsSchema>
8298
multiple?: boolean
83-
currencyCode?: string
84-
},
99+
} & ColumnTypeMetadata,
85100
ctx: z.RefinementCtx
86101
): void {
87-
// `currencyCode` on a non-currency column is inert until a later
88-
// convert-to-currency inherits it, silently overriding the currency the user
89-
// picked in that request.
90-
if (data.type !== undefined && data.type !== 'currency' && data.currencyCode !== undefined) {
91-
ctx.addIssue({
92-
code: 'custom',
93-
path: ['currencyCode'],
94-
message: 'currencyCode is only allowed on currency columns',
95-
})
96-
}
97-
if (data.type === 'select') {
98-
if (!data.options || data.options.length === 0) {
99-
ctx.addIssue({
100-
code: 'custom',
101-
path: ['options'],
102-
message: 'A select column must define at least one option',
103-
})
104-
}
105-
return
106-
}
107-
if (data.type === undefined) return
108-
if (data.options && data.options.length > 0) {
102+
// A select column must actually declare options — checked before the
103+
// ownership sweep so the message is about what is missing, not what is extra.
104+
if (data.type === 'select' && (!data.options || data.options.length === 0)) {
109105
ctx.addIssue({
110106
code: 'custom',
111107
path: ['options'],
112-
message: 'options are only allowed on select columns',
108+
message: 'A select column must define at least one option',
113109
})
114110
}
115-
// `multiple` stored on a non-select column is inert until a later
116-
// convert-to-select inherits it, silently producing a multiselect.
117-
if (data.multiple) {
111+
// Skipped when `type` is absent: a metadata-only update on an existing column
112+
// carries no type to check ownership against, and the server re-checks it
113+
// against the stored one.
114+
if (data.type === undefined) return
115+
116+
// Every type-specific key, against the registry's ownership map. A key on a
117+
// type that does not own it is inert until a later conversion inherits it and
118+
// silently overrides what that request asked for — `currencyCode` riding onto
119+
// a convert-to-currency is the case this originally guarded, and the sweep
120+
// now covers each new key without an edit here.
121+
for (const key of TYPE_SPECIFIC_COLUMN_KEYS) {
122+
if (data[key] === undefined || data[key] === false) continue
123+
if (key === 'options' && (!data.options || data.options.length === 0)) continue
124+
const owners = METADATA_KEY_OWNERS[key]
125+
if (owners.includes(data.type)) continue
118126
ctx.addIssue({
119127
code: 'custom',
120-
path: ['multiple'],
121-
message: 'multiple is only allowed on select columns',
128+
path: [key],
129+
message: `${key} is only allowed on ${owners.join(' / ')} columns`,
122130
})
123131
}
124132
}
@@ -191,6 +199,10 @@ export const tableColumnSchema = z
191199
options: selectOptionsSchema.optional(),
192200
/** A `select` column that accepts multiple options per cell. */
193201
multiple: z.boolean().optional(),
202+
/** Decimal places for a `number` / `percent` column. */
203+
precision: precisionSchema.optional(),
204+
/** Whether a `date` column carries a time of day. */
205+
includeTime: z.boolean().optional(),
194206
/** ISO 4217 code for a `currency` column. */
195207
currencyCode: currencyCodeSchema.optional(),
196208
})
@@ -270,6 +282,8 @@ export const createTableColumnBodySchema = z.object({
270282
position: z.number().int().min(0).optional(),
271283
options: selectOptionsSchema.optional(),
272284
multiple: z.boolean().optional(),
285+
precision: precisionSchema.optional(),
286+
includeTime: z.boolean().optional(),
273287
currencyCode: currencyCodeSchema.optional(),
274288
})
275289
.superRefine(refineColumnOptions),
@@ -286,6 +300,8 @@ export const updateTableColumnBodySchema = z.object({
286300
unique: z.boolean().optional(),
287301
options: selectOptionsSchema.optional(),
288302
multiple: z.boolean().optional(),
303+
precision: precisionSchema.optional(),
304+
includeTime: z.boolean().optional(),
289305
currencyCode: currencyCodeSchema.optional(),
290306
})
291307
.superRefine(refineColumnOptions),

apps/sim/lib/table/__tests__/column-type-registry.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ describe('registry shape', () => {
3636

3737
it('falls back to string for an unknown type instead of throwing', () => {
3838
// A malformed or future schema must render as text, not crash mid-render.
39-
expect(columnTypeById('percent').id).toBe('string')
39+
expect(columnTypeById('geolocation').id).toBe('string')
4040
expect(columnTypeById(undefined).id).toBe('string')
41-
expect(isColumnType('percent')).toBe(false)
41+
expect(isColumnType('geolocation')).toBe(false)
4242
expect(isColumnType('currency')).toBe(true)
4343
})
4444

@@ -173,19 +173,19 @@ describe('metadata ownership', () => {
173173
const options = [{ id: 'opt_a', name: 'A' }]
174174

175175
it.each`
176-
label | definition | valid | needle
177-
${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''}
178-
${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'}
179-
${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'}
180-
${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'}
181-
${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''}
182-
${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'}
183-
${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'}
184-
${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'}
185-
${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'}
186-
${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''}
187-
${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'}
188-
${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'}
176+
label | definition | valid | needle
177+
${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''}
178+
${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'}
179+
${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'}
180+
${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'}
181+
${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''}
182+
${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'}
183+
${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'}
184+
${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'}
185+
${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'}
186+
${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''}
187+
${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'}
188+
${'unknown type'} | ${column({ type: 'geolocation' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'}
189189
`(
190190
'rejects $label',
191191
({

apps/sim/lib/table/column-types/boolean.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TypeBoolean } from '@sim/emcn/icons'
22
import type { ColumnTypeDefinition } from '@/lib/table/column-types/types'
3+
import { ownedKeysOf } from '@/lib/table/column-types/types'
34

45
export const booleanColumnType: ColumnTypeDefinition = {
56
id: 'boolean',
@@ -9,7 +10,7 @@ export const booleanColumnType: ColumnTypeDefinition = {
910
storesOpaqueIds: false,
1011
supportsUnique: true,
1112
sampleValue: true,
12-
ownedMetadata: [],
13+
ownedMetadata: ownedKeysOf('boolean'),
1314
workflowInputType: 'boolean',
1415
// Toggled in place on click, Enter, and fill — never opens an editor, so it
1516
// has no `typeaheadPattern` and the expanded popover skips it entirely.
@@ -34,6 +35,12 @@ export const booleanColumnType: ColumnTypeDefinition = {
3435
return String(value)
3536
},
3637

38+
// Draws even when the cell is empty: an absent boolean is an unchecked box,
39+
// not a blank, so every boolean cell reads as a clickable toggle.
40+
display(value) {
41+
return { kind: 'boolean', checked: Boolean(value) }
42+
},
43+
3744
formatForInput(value) {
3845
return String(value)
3946
},

apps/sim/lib/table/column-types/currency.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TypeCurrency } from '@sim/emcn/icons'
22
import type { ColumnTypeDefinition } from '@/lib/table/column-types/types'
3+
import { ownedKeysOf } from '@/lib/table/column-types/types'
34
import {
45
formatCurrencyDisplay,
56
formatCurrencyForInput,
@@ -16,7 +17,7 @@ export const currencyColumnType: ColumnTypeDefinition = {
1617
storesOpaqueIds: false,
1718
supportsUnique: true,
1819
sampleValue: 123,
19-
ownedMetadata: ['currencyCode'],
20+
ownedMetadata: ownedKeysOf('currency'),
2021
workflowInputType: 'number',
2122
editor: 'text',
2223
expandable: false,

apps/sim/lib/table/column-types/date.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
11
import { Calendar as CalendarIcon } from '@sim/emcn/icons'
22
import type { ColumnTypeDefinition } from '@/lib/table/column-types/types'
3+
import { ownedKeysOf } from '@/lib/table/column-types/types'
34
import {
45
formatDateCellDisplay,
56
normalizeDateCellValue,
67
storedDateToEditable,
78
} from '@/lib/table/dates'
8-
import type { JsonValue } from '@/lib/table/types'
9+
import type { ColumnDefinition, JsonValue } from '@/lib/table/types'
10+
11+
/**
12+
* Drops the time of day from a normalized value when the column is date-only.
13+
*
14+
* `normalizeDateCellValue` already returns a bare `YYYY-MM-DD` for input that
15+
* carried no time, so this only bites when a time arrives anyway — a paste, a
16+
* CSV cell, a tool write. Without it a "Due date" column silently accumulates
17+
* instants, and two rows entered the same day stop comparing equal.
18+
*
19+
* A calendar date is a prefix of the wall-instant form, so the truncation is a
20+
* slice rather than a re-parse; going through `Date` would reintroduce exactly
21+
* the timezone conversion this storage shape exists to avoid.
22+
*/
23+
function applyIncludeTime(normalized: string, column: ColumnDefinition): string {
24+
// Only an EXPLICIT `false` truncates. An absent flag means a column created
25+
// before this key existed, and those columns hold instants — defaulting them
26+
// to date-only would silently truncate a stored time on the next write to any
27+
// cell. New columns get `includeTime: false` stamped at creation instead, so
28+
// the good default applies going forward without rewriting history.
29+
if (column.includeTime !== false) return normalized
30+
return normalized.slice(0, 10)
31+
}
932

1033
export const dateColumnType: ColumnTypeDefinition = {
1134
id: 'date',
@@ -15,24 +38,27 @@ export const dateColumnType: ColumnTypeDefinition = {
1538
storesOpaqueIds: false,
1639
supportsUnique: true,
1740
sampleValue: '2024-01-31',
18-
ownedMetadata: [],
41+
ownedMetadata: ownedKeysOf('date'),
1942
workflowInputType: 'string',
2043
editor: 'date',
2144
expandable: false,
2245
typeaheadPattern: /[\d\-/]/,
2346
parseErrorMessage: 'Invalid date',
2447

25-
coerce(value) {
48+
coerce(value, column) {
2649
if (typeof value === 'string') {
2750
const normalized = normalizeDateCellValue(value)
28-
return normalized === null ? { ok: false } : { ok: true, value: normalized }
51+
if (normalized === null) return { ok: false }
52+
return { ok: true, value: applyIncludeTime(normalized, column) }
2953
}
3054
// Date instances and epoch numbers may still be out of the representable
3155
// range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on
3256
// an Invalid Date, so an over-range value degrades to `{ ok: false }`
3357
// rather than crashing the write.
3458
const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null
35-
if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() }
59+
if (date && !Number.isNaN(date.getTime())) {
60+
return { ok: true, value: applyIncludeTime(date.toISOString(), column) }
61+
}
3662
return { ok: false }
3763
},
3864

@@ -53,11 +79,24 @@ export const dateColumnType: ColumnTypeDefinition = {
5379
return valid ? null : `${column.name} must be valid date`
5480
},
5581

82+
display(value) {
83+
if (value === null || value === undefined) return { kind: 'empty' }
84+
return { kind: 'date', text: String(value) }
85+
},
86+
5687
formatForDisplay(value) {
5788
return formatDateCellDisplay(String(value), { seconds: true })
5889
},
5990

6091
formatForInput(value) {
6192
return storedDateToEditable(String(value))
6293
},
94+
95+
// Stamped only on creation, so a NEW date column is date-only by default —
96+
// the right shape for the due dates and birthdays most date columns hold —
97+
// while a column that predates the key keeps its instants (see
98+
// `applyIncludeTime`).
99+
defaultMetadata(column) {
100+
return { includeTime: column.includeTime ?? false }
101+
},
63102
}

0 commit comments

Comments
 (0)