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 markednodragfor 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. Az-30drop shadow over the grid keeps the rail visually layered.- Per-table settings gear on hover — opens
EditTableSheetdirectly. 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 narrowreorder_filter_presetsSECURITY 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 writessort_orderonly and is gated on org membership plus per-row SELECT-equivalent visibility. Migration154_filter_presets_sort_order.sqladds 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+applyTableFilterspipeline 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.
- Per-table settings gear on hover — opens
- Top bar — grid toolbar — the active table's
ViewSelectdropdown, 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
ViewSelectdropdown 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.
- The table's name doubles as the
- 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-mixof the accent tints over card), so the pinned column no longer sits as a flatbg-cardband on top of an otherwise highlighted row. - Hide / unhide columns. The column-header menu carries a
Hide columnaction; hidden columns persist via ahiddenflag on column metadata (same mechanism asfrozen; 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 onecolumnsWithFlagshelper.
- 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
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
ViewSettingsSheetas a centered modal on the sharedModalShell, mirroringCreateViewModalandEditTableSheet: solidbg-backgroundpanel atmax-w-5xl h-[85vh], tintedbg-sidebar/50config rail on the left,SectionHeadingrhythm 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
FilterTreeEditorthe table's Default Filter uses; seeded throughapplyPresetToWidgetso rules on columns that no longer exist are dropped at open time - Sort — the same
SortListEditorthe table's Default Sort uses (SortListEditor/TableFilterValueInput/makeSortIdmoved out ofDataTableViewerWidget.jsxintoshared/tableSortFilterEditors.jsxto 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
filterPresetsstore directly, so the sidebar tree, the header dropdown, anduseTableViews'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
useTableViewsto 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.stringifydiff of filter tree + sorts vs. snapshot. - Sorts are part of the view. Migration
115adds asortscolumn tofilter_presets;presetSchemavalidates the new shape andapplyPresetToWidgetdrops any sort whose field went missing. - Cross-channel sync. Picking a preset from inside the Filter
dropdown also updates the title's active view —
FilterMenuWithPresetsemitsonPresetApplied / onPresetSaved / onPresetDeletedso 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
ViewSelectdropdown, and the New view entry inside the mediumDataTableViewerWidgetcanvas widget's dropdown all open the same modal. The old inline name-onlyNewViewFormis 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/applyTableSortsengines via acomputePreviewcallback, 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 (sameshared/no-cycle rule that moved the sort / filter editors intotableSortFilterEditors.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
ensureTagAssignmentsbefore 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
optionsand any values already present in the loaded rows (via the sharedenrichSelectFieldOptionshelper), 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.createViewnow 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:
| Type | Menu |
|---|---|
| all | Filled, 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 ownROW_FETCH_LIMITor by PostgREST'sdb-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 left —
Clear all. Inside a table widget, clearing routes throughuseTableViews.clearView, so the title'sViewSelectand 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.
Header search
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_tagscolumn. Off hides the synthetic_tagscolumn in the data-table viewer while keeping existing assignments intact. Useful for tables likeclientswhere 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
formatis unset. Resolution order iscol.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 (
usePanelResizewithedge: '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
notificationstable (categoryrecords). 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_bycomparison 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.
RecordDeepLinkHostrefetches 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_titletags(text array)source,status,opted_in_sms,opted_in_emailcity,state,countrylast_contacted_at,conversation_countcustom_fieldsJSONB 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
Pages
The Notion-style document mode — unified Pages workspace widget with tree, folders, and block editor side-by-side.
Calendar
Project88's universal calendar — Google dual-write, a default Project 88 calendar that mirrors every internal write, shareable calendars with on-behalf-of booking and visibility tiers, reminders, record linking, and tasks on the schedule.