Apps

Data

The Airtable-style data mode — typed tables, inline editing, and the native People table.

Data is the structured-data mode. It shows your tables flat with inline editing, sorting, and filtering.

Data opens as a native shell surface — full-bleed, no React Flow canvas underneath. See Canvases and widgets → native apps vs canvases. Dashboard-style data widgets (DataTableWidget, DataTableListWidget, DataTableViewerWidget, DataRecordWidget, DataStatsWidget) remain addable to Home and any board for embedded views.

Layout

DataWorkspace combines what used to be two separate canvas widgets — the table list and the grid — into one master / detail surface mounted by DataSurface (which wraps a ReactFlowProvider so the grid's useReactFlow hooks keep working). On the native surface it runs as the shared two-column shape Calendar and Dial use: a full-height table-list sidebar on the left, with the grid's toolbar riding the chrome's darker top bar over the inset white card on the right (the shared NativePaneChrome):

  • Left rail (full-height) — elevated list of every table in the org with icon, row count, and SYS badge for system tables; click any row to switch. Rows are drag-to-reorder — grab a table and drop it above / below any other row and the order persists via reorderTables. Table drags carry a dedicated MIME type so they can't cross-react with pipeline-card drags, and the rows are marked nodrag for React Flow. The "Data" title + New table button live in the top of the rail (not on the chrome top bar), so the rail reads as the app's primary nav. A z-30 drop shadow over the grid keeps the rail visually layered.
    • Per-table settings gear on hover — opens EditTableSheet directly. Duplicate, Export CSV / JSON, and Delete Table (behind an explicit confirm step) now live inside the sheet's Settings tab under Actions and Danger Zone rather than in a kebab menu, so a mis-click can't wipe a whole table.
    • Views tree under the focused table — the active table expands (Obsidian / VSCode file-tree style) into All records, every saved view, Archived, and a Create view row that opens the Create view modal, indented behind a vertical guide. Every table row carries an always-visible chevron; collapsing the tree drops the highlight back to the table row. Exactly one row carries the selection highlight at a time.
    • Views are drag-to-reorder with the same styling as table rows — grab a saved-view row and drop it above / below any other view and the order persists via reorderPresets. Order flows into the grid header's view dropdown, too, since both surfaces read the same preset cache. System rows (All records, Archived, Create view) are fixed. Drags use a dedicated MIME type so table drags and view drags can't cross-react. Persistence goes through a narrow reorder_filter_presets SECURITY DEFINER RPC (RLS only lets a view's owner UPDATE it, but a drag must be able to move teammates' org-shared views) — the RPC writes sort_order only and is gated on org membership plus per-row SELECT-equivalent visibility. Migration 154_filter_presets_sort_order.sql adds the column and backfills the existing 16 presets in their current display order, so nothing visibly shuffles on first load.
    • Record counts next to view names — every view row (sidebar tree and the grid header's view dropdown) shows how many records it matches. All records shows the table total; each saved view shows its filtered count; Archived shows once its cache has loaded (hidden until then to avoid a misleading flash of 0). Counts run through the same applyPresetToWidget + applyTableFilters pipeline the grid filters with — link resolution, tag assignments, child-link rollups, week-start — so a view's count always equals what clicking it would show. They reflect the saved definition; dirty filter edits, the search box, and pagination don't move them.
  • Top bar — grid toolbar — the active table's ViewSelect dropdown, header search input, and the right-side action set (filter, sort, import, insert) all ride the darker top bar over the right column.
    • The table's name doubles as the ViewSelect dropdown trigger, modeled on the dial bucket selector. The dropdown lists All records plus every saved view with each row's record count; a hover gear per row opens the view settings sheet (see Saved views).
    • The header search input is borderless until focus.
    • Filter and sort dropdowns right-align to their trigger; Import is an IconButton.
    • Because the rail already drives the active table, the older in-grid table switcher is hidden inside the workspace.
  • Table grid — the active table rendered flat in the inset white card. Click any cell to edit.
    • Pinned (sticky) columns pick up row states. Frozen cells now paint the same row hover / focus / selection tints as the scrolling body via new opaque bg-row-* theme tokens (color-mix of the accent tints over card), so the pinned column no longer sits as a flat bg-card band on top of an otherwise highlighted row.
    • Hide / unhide columns. The column-header menu carries a Hide column action; hidden columns persist via a hidden flag on column metadata (same mechanism as frozen; hiding also unfreezes). The toolbar's eye-off button lists every hidden column with click-to-unhide and Unhide all. Freeze and hide handlers share one columnsWithFlags helper.

The legacy DataTableListWidget / DataTableViewerWidget node types still render on canvases that already use them — DataTableListBody is shared between the rail and the standalone widget so layout stays in sync. The full-height-sidebar arrangement is the native opt-in (nativeLayout); the canvas widget keeps a single-column chrome with Data + New on the card's top bar and the grid's toolbar inside the right pane.

Saved views

A view is a named snapshot of the data-table's filter tree and sort priority, saved against the table. Pick one from the title's ViewSelect, or from the sidebar tree under the focused table, and the grid filters + sort populate; the title updates to the view name.

  • Create in a modal with live preview. See Create view modal below — one modal covers every entry point (sidebar tree, grid header dropdown, and the medium canvas widget's dropdown) with a live count and record sample that reflects the draft filters + sort as you edit.
  • Rename / delete / scope / edit filters + sort in a centered settings modal. A hover gear on every view row — in the sidebar tree and per-row in the grid header's view dropdown — opens ViewSettingsSheet as a centered modal on the shared ModalShell, mirroring CreateViewModal and EditTableSheet: solid bg-background panel at max-w-5xl h-[85vh], tinted bg-sidebar/50 config rail on the left, SectionHeading rhythm on each pane, and a pinned footer with Cancel + primary Save (create/save errors surface in the footer). Sections:
    • Name
    • Scope — a private / share with everyone in org toggle
    • Filters — the same FilterTreeEditor the table's Default Filter uses; seeded through applyPresetToWidget so rules on columns that no longer exist are dropped at open time
    • Sort — the same SortListEditor the table's Default Sort uses (SortListEditor / TableFilterValueInput / makeSortId moved out of DataTableViewerWidget.jsx into shared/tableSortFilterEditors.jsx to break the grid → ViewSelect → ViewSettingsSheet → grid import cycle)
    • Delete — behind an explicit confirm Untouched sections are never written back on save. The sheet writes through the filterPresets store directly, so the sidebar tree, the header dropdown, and useTableViews's active-view pointer stay in sync without extra bridge plumbing. Deleting the active view flips the title back to All records.
  • Active view re-applies when its saved definition changes externally. Editing the current view via the settings sheet (or a teammate re-scoping / editing an org-shared view) triggers useTableViews to re-apply the ACTIVE view's fresh definition — unless the grid has unsaved local tweaks, which always win. Content comparison strips node ids since legacy flat presets regenerate ids on normalize and would otherwise loop the effect.
  • Dirty-state indicator. When the live filters/sort diverge from the saved view's snapshot, an unsaved dot appears next to the title and the dropdown gains a "Save changes to ‹view›" footer. Click it and the dot clears. Dirty detection is a JSON.stringify diff of filter tree + sorts vs. snapshot.
  • Sorts are part of the view. Migration 115 adds a sorts column to filter_presets; presetSchema validates the new shape and applyPresetToWidget drops any sort whose field went missing.
  • Cross-channel sync. Picking a preset from inside the Filter dropdown also updates the title's active view — FilterMenuWithPresets emits onPresetApplied / onPresetSaved / onPresetDeleted so the two surfaces stay in sync without a second source of truth.
  • Scope. Workspace scope was dropped from the user-facing surface; the scope picker is now a private / org toggle. System views (All records, Archived) never render a gear — they can't be renamed, rescoped, or deleted.

Internally, views are still filter_presets rows (useTableViews is a shared hook so Medium + Large widgets stay in sync). The active view id lives in component useState only — it's not persisted across reloads.

Create view modal

Creating a view opens CreateViewModal — a centered modal on the shared ModalShell, with the whole view-configuration surface in a tinted left rail (name, sharing, filters, sort) and a live record preview filling the rest. What the view will show is visible before it exists.

The modal, the Edit View settings modal, and the Edit Table settings modal all render on the same shared ModalShell — dimmed blurred backdrop, solid bg-background panel at rounded-2xl + sheet elevation, title / subtitle header with close button, pinned footer bar, and popover-aware Escape (an open dropdown or date-picker closes only its own layer; the modal closes only when no popover is open). The rail width is 400 px so filter-value dropdowns show the picked option's full label — earlier iterations collapsed it to a sliver.

  • One modal, three entry points. The sidebar tree's Create view row, the New view entry inside the grid header's ViewSelect dropdown, and the New view entry inside the medium DataTableViewerWidget canvas widget's dropdown all open the same modal. The old inline name-only NewViewForm is gone.
  • Live preview. A header line reads "123 of 951 records match" and updates as the draft filter tree or sort list changes. Below it, up to 50 matching records render as a compact grid using the same visible columns as the underlying table (extra rows are indicated with a "showing 50 of N" footer). Preview counts flow through the grid's own applyTableFilters / applyTableSorts engines via a computePreview callback, so the count you see in the modal is exactly the count the created view will show — link resolution, tag assignments, child-link rollups, and week-start all match. The grid's engines still own the filter logic; the modal only accepts a callback (same shared/ no-cycle rule that moved the sort / filter editors into tableSortFilterEditors.jsx).
  • Seeded from the grid. The modal opens with the grid's current filters and sort as its draft, preserving the "filter the grid, then save what I see" muscle memory — you can still tweak anything before saving.
  • Full column pool. The filter and sort editors use buildFilterableCols — the same column pool the Edit View sheet uses — so the medium canvas widget is no longer capped to its four displayed columns when building a view.
  • Tags correctness. A tags-column filter in the draft prefetches tag assignments through the refactored ensureTagAssignments before the preview counts, so tag-filtered previews can't silently under-count.
  • Value picker sees stored options ∪ row values. The filter-value dropdown for a select / multi-select column offers the union of the column's stored options and any values already present in the loaded rows (via the shared enrichSelectFieldOptions helper), so a ghost value entered on a single row is still filterable while the option is being canonicalized. Row-only entries render with a subtle "not in options" hint. The same enrichment powers the grid's filter popover and the Edit View settings modal.
  • Create = save + apply. On success the grid applies the new view's filters + sort, resets to page 0, and marks the view active (not dirty). useTableViews.createView now accepts explicit draft filters / sorts alongside the legacy live-snapshot signature that keeps older call sites working.
  • Escape / backdrop / Cancel all close without saving; errors from the create RPC surface inline in the modal footer. With an open value dropdown or date popover, Escape closes only the popover — a second Escape closes the modal, so a mis-typed refinement never drops the whole draft.

Totals row

Every data-table view carries a per-column summary footer at the bottom of the grid — the Airtable / Notion pattern, one aggregation picker per column scoped to that column's type:

TypeMenu
allFilled, Empty, Unique, % Filled, % Empty
number price percent formula child-link+ Sum, Avg, Min, Max, Median, Range
date datetime+ Earliest, Latest, Range
boolean+ Checked, Unchecked, % Checked
select multi-select+ Most common

Percent columns list Avg first — Sum is still available for allocation-style columns that should total 100%, but summing a rate column is almost always a mistake.

Scoped to the filtered rows, never the page. Paging can't move a total; filtering always does. So the number under a filtered view reflects that view's rows, not just the ones on screen.

Stored per view rather than per column. A view is a saved perspective — "Won Deals" can total value where "All Leads" counts records. Changing a summary marks the view dirty and saves through the same Save button as any other view edit; it never auto-saves. Persistence is filter_presets.summaries (migration 159), a jsonb map alongside the view's filters and sorts.

Never shows a number it can't stand behind. Each cell's result carries a { value, state, reason } shape:

  • ok — exact for the rows we have.
  • partial — the row fetch was truncated (either by our own ROW_FETCH_LIMIT or by PostgREST's db-max-rows); the value shows with a marker and a tooltip rather than passing as complete. Truncation is detected by comparing the rows returned against an exact count in the same round-trip.
  • uncomputable — a formula or child-link column above the existing 5,000-row rollup cap; renders , not a guess.

The footer's own record count ("N records" / "N of M records") uses the same partial-flag semantics, so a clipped grid can't read "1000 records" for a 1002-row table.

Archive and the Archived view

The bulk-action toolbar's destructive button is Archive rather than Delete. Selected rows pick up an archived_at timestamp and drop out of the active view but remain in the database.

ARCHIVED_SYSTEM_VIEW is always appended to every table's view list — pinned at the bottom of the picker below a divider with the Archive icon. It can't be renamed, deleted, or saved over; user filters and sorts are ignored inside it. Inside the Archived view, the bulk-action set switches to Export / Restore / Permanently delete — the last gated by a ConfirmDialog. Per-row affordances on RecordDetailSheet, DataPage, and DataWorkspace rename Delete to Archive with the same two-click confirm pattern.

The shared getBulkActions(view) helper returns the action list per view kind, so both the Medium and Large DataTableViewerWidget toolbars stay in lockstep. See Data tables → Archive vs. delete for the storage shape.

Range operator on date filters

Date and datetime columns expose an in period (range) operator in the filter menu with five built-in choices:

  • This week / Last week — boundaries follow the user's Week start day preference.
  • This month / Last month — calendar-month boundaries.
  • Custom range — expands two inline DatePickers for an explicit from → to window (end day inclusive).

Values are stored as objects: { period: 'this_week' } for named periods, { period: 'custom', start, end } (YYYY-MM-DD) for custom. Boundary math lives in src/lib/dateRanges.js — pure and DST-safe — and applyTableFilters precomputes the resolved window once per pass via precomputePeriodRanges so the boundary is stable across all rows. The same primitive backs the dialer bucket builder's date filters.

Filter menu layout

The filter menu's chrome is laid out for two-handed editing of presets

  • rules in one place:
  • Header right — the preset selector dropdown. The trigger shows the active preset's name (falls back to Apply preset… when no preset is active).
  • Footer leftClear all. Inside a table widget, clearing routes through useTableViews.clearView, so the title's ViewSelect and the filter chips clear in lockstep with the underlying tree.
  • Footer right — primary Save button. Disabled until the current tree diverges from the snapshot that was last applied, re-enabled the moment any rule, group, or sort changes.

Picking a preset from inside the filter dropdown emits onPresetApplied / onPresetSaved / onPresetDeleted, which keeps the title's active view and the filter menu in sync without a second source of truth. The custom-range date picker inside the menu now renders into the popover layer (data-popover-layer) so picking a day doesn't collapse the menu.

Both the data-table title row and the calendar week widget header now host a borderless-until-focus search input. The data-table search matches any column, and phone-typed columns match with formatting stripped — 3176449695 or 317-644 will find a stored (317) 644-9695. Same lib/searchMatch.js helper used by the dialer header search.

Creating a table

Click the breadcrumb dropdown → + New table. The NewTableModal asks for:

  • Name and slug
  • Lucide icon
  • Initial columns

Columns can be added later via the toolbar.

Per-table settings

Opening a table's gear launches EditTableSheet — despite the name, it now renders as a centered modal on the shared ModalShell (same chrome as CreateViewModal and ViewSettingsSheet): the table's name plus field-count subtitle in the header, a Fields and Settings tab bar pinned above the scrolling body, and a Cancel + primary Save footer. All tab content is unchanged — the switch is chrome-only.

The Settings tab is laid out as collapsible Section blocks. Two recent additions:

  • Enable tags — flips the use_tags column. Off hides the synthetic _tags column in the data-table viewer while keeping existing assignments intact. Useful for tables like clients where tagging isn't part of the workflow.
  • Default formats — per-type presets (date, datetime, time, number, price, percent, phone, email) used as a fallback when a column's own format is unset. Resolution order is col.format → table default → hardcoded default. The resolver is threaded through every render path (data-table viewer rows, the record detail sheet, the record editor, child-link rollups, and the date / phone pickers in edit mode), so the value you see while editing matches the value you see while reading.

Both settings default to behavior-preserving values — existing tables aren't affected until you toggle them.

Column types

The full registry lives in src/modals/recordEditor/colTypes.jsx. There are 15 column types — see Data tables for the full table. The short list:

text, number, phone, email, price, percent, date, datetime, time, boolean, select, multi-select, link, child-link, formula.

Inline editing

Click any cell to edit. Updates are optimistic — the cell updates immediately, then persists. On error, the cell rolls back and a toast explains.

Drag a row's grip to reorder. Save status indicators ("Saving..." / "Saved" / "Save failed") appear in the toolbar.

Created select options persist to the column. The Create ⟨value⟩ action in a select / multi-select cell editor no longer only writes the value onto the row — it also appends it to the column's stored options list via the shared addSelectOption helper (no-ops for duplicates, blanks, non-select columns, and value-set-backed columns, whose options live in the value-set store). This fixes "ghost" options that used to vanish from pickers when the last row holding them was edited, and that never appeared in surfaces reading only stored options — notably the Create view modal's filter value dropdown. Existing ghost values remain filterable without a data migration: filter-value pickers show the union of stored options and current row values (enrichSelectFieldOptions). The grid's inline cell editor, CanvasDataCard, and DataPage share the same create path.

Record detail sheet

RecordDetailSheet is the right-side drill-in for a single row. Its layout is optimized for reading and quick action:

  • Archive / Edit / Close live in the title row, in that order. Archive still requires a two-click confirm; the old header metadata strip was removed to give the field values the space.
  • Tags render as a labeled TAGS section above Linked and Children (mirrored in edit mode) — no longer sharing space with the header metadata.
  • Drag-to-resize width. A left-edge grip (usePanelResize with edge: 'left') lets you widen or narrow the sheet; the width persists across records. Edit mode and add-child mode floor the width so their layouts still fit. Compact field values truncate to one line with an ellipsis and a hover tooltip instead of wrapping — widening the sheet reveals more without opening the field.
  • Open in Dial. When any dial bucket sources the record's table, a Phone action appears in the title row that deep-links to /:org/dial?leadTable=&leadRow=. The dialer consumes the params via the existing search-detour semantics — the lead displays via the bucket's render config but never enters the queue and never bypasses filters, cap, cooldown, or the active-appointment check (same contract as Dial → header search).

Tabs and live count badges

The record sheet's tab bar carries four tabs, in that order:

  • Details — the field grid (default).
  • Events — every calendar event linked to the record (via calendar_event_links), listed newest first.
  • Tasks — every task linked to the record, grouped Overdue / Open / Completed, with a per-record composer (title + optional due date / time). Reuses DialTasksTab — the dialer's per-lead task panel — with record-flavored wording, so both surfaces share one composer, cache, and task-bus sync. Toggling a task in this tab syncs the top-bar Tasks pill, the org-wide Tasks widget, and the calendar's task chips via the shared task bus.
  • Activity — logged calls, notes, tag changes, and other activity events for the record.

Live count badges. Events, Tasks, and Activity each show a live count badge in the tab bar — fetched up-front as an exact server-side head count so the numbers show before a tab is ever opened. useRecordTabCounts runs one count: 'exact', head: true query per data source (api.calendarEventLinks.countByRow, api.recordActivities.countByRow, plus a task-bucket count) and refetches on task_changed / row_bumped / activity_removed scoped to the row. Badges hide at zero (matching the app's other count precedents); ModalTabBar gained an opt-in count on the tab shape, so its six other consumers are unaffected. The new activity_removed bus channel exists specifically so badges can shrink on delete — deletes don't fire the monotonic last_activity_at bump (that pinned contract is preserved and still tested), but badge consumers need to know the activity set shrank.

Toast notifications on record creation

Whenever a record is created — from Data, from an import, from a convert-to-record, from Dial, from anywhere — a toast fires:

  • Creator side. After addRow / addRowWithChildren, the creator sees a success toast with an Open record action that deep-links to the row.
  • Teammate side. The creation also inserts an org-scoped row in the notifications table (category records). Everyone else in the org receives the same toast via the existing notifications realtime channel, and the bell popover gets a clickable history row with a Database icon.

Deep link. The toast's Open action navigates to /:orgSlug/data?table=<slug>&row=<id>. A new RecordDeepLinkHost consumes that URL and opens RecordDetailSheet on top of the current view. The routeFor('user_table_row') helper — already used by the command-palette record results — points at this same URL, so palette results now open the sheet cleanly instead of dead-ending.

Guardrails (each with matching store tests):

  • No creator echo — a metadata.created_by comparison suppresses the realtime toast on the person who created the row (they've already seen the local one).
  • 10-minute staleness window silences reconnect backfill so a network reconnect can't toast-storm the room with old rows (mirrors the Gmail toast guard).
  • Import and convert-to-record flows aggregate into single notifications — no toast storms for CSV imports or bulk conversions.
  • A new Records notification-preference category (default on) gates teammate toasts — an operator who doesn't want them can turn the category off without losing the creator-side toast.
  • Stale deep links (deleted row, renamed table slug) clear themselves instead of dead-ending on the sheet. RecordDeepLinkHost refetches the target table before declaring a row missing, so a notification pointing at a row newer than the local cache — the teammate-notification race — opens the sheet instead of being silently discarded. The clear happens from an effect after lookup settles (never during render), avoiding a React Router v7 render-phase-navigation warning that used to leave the URL out of sync with the sheet.

Architecture: DataProvider emits record_created / records_imported on the shared activityBus rather than toasting itself, so store tests can mount DataProvider bare (no toast / router deps). A new RecordCreatedNotifier, mounted at App level, converts bus events into the creator toast + notification insert. The notifications table already had org-member INSERT RLS and realtime publication membership, so no migration was needed.

The People table

Your org's first table is People, created automatically via the ensure_people_user_table() SQL function. It has typed Postgres columns optimized for scale (100k+ rows):

  • first_name, last_name, email, phone, company, job_title
  • tags (text array)
  • source, status, opted_in_sms, opted_in_email
  • city, state, country
  • last_contacted_at, conversation_count
  • custom_fields JSONB for user-defined columns

The Data mode shows it like any other table, but with a SYS badge. The SMS mode and Campaigns mode read from it directly.

Tables as agent context

Agents can query your tables with the built-in search_user_tables tool. That's how you give an agent access to your CRM, ticket queue, or product catalog without writing any glue code.

Where to go next

On this page