Skip to content

Entity Table

Renders rows from an entity source as a table whose columns come from the schema, with server-side sorting and paging, a toolbar, a pagination footer, row selection, column sizing, resizing, ordering, pinning and grouping, and inline edit.

Terminal window
pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/entity-table.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/entity-table.json
Entity Table: 320 Versions in pages of 25, with a filter bar, a column picker and a sort picker in the toolbar. Pages, Load more and Scroll switch how the set is walked. An editable cell lights up under the pointer; a double-click or Enter opens its editor in a popover; Popover editor and Inline editor switch where every editor opens.

React

Loading the React demo…

Svelte

Loading the Svelte demo…

Build the source and the columns once, then hand both to the table.

const context = createSgContext({ client });
const source = createEntitySource({
client: context.client,
entityType: 'Version',
fields: ['code', 'entity', 'sg_status_list', 'image', 'description', 'user'],
pageSize: 25,
});
const columns = await resolveColumns(context.schema, 'Version', ['code', 'entity', 'sg_status_list']);

paging says how the set is walked, and the source follows it, so a caller sets one prop rather than two.

value the table draws
pages The footer’s pager: rows per page, a page number, and n to m of N once the set is counted.
more A load-more row under the last row. The footer counts what is loaded.
scroll The next page arrives when the scroller reaches the last loaded row. A skeleton row sits at the bottom while it does, and the footer counts what is loaded.

The table defaults to pages. A table is read as a spreadsheet, where a row’s place in the set is part of what it means, and a caller who walks to page 12 wants to come back to it.

more and scroll append: the rows already loaded stay and the new page lands under them, so a group that spans a page boundary stays one group and its count grows. A page that fails leaves its rows and puts one error line under them with a retry.

Entity Table: the same rows walked with a load-more row, with the programmatic path beside each header

React

Loading the React demo…

Svelte

Loading the Svelte demo…

proptypedefaultmeaning
columnsCollectionColumn[]requiredColumns in display order, from resolveColumns. Two-way: hiding one writes the shorter list back.
statusesRecord<string, StatusRecord> | nullnullStatus rows by code, for status cells.
contextSgContext—The widget context. Cells render with its preferences, a cell editor reads through it, and an entity cell links to the row's page when it carries a site.
projectIdnumber—The project the columns were resolved with. Scopes a status, list or entity cell editor.
precisionnumber—Decimals a float cell keeps.
symbolstring'$'Shown before the value in a currency cell.
density'compact' | 'default''default'Compact halves the vertical cell padding and draws a cell chip and badge a step smaller.
selectablebooleanfalseDraws a checkbox column; a press on a row selects it, with Shift for a range.
onSelectAllMatching() => void | Promise<void>—Offers every row the filter matches once every loaded row is selected and the set holds more. The host reads the set and writes the selection.
groupBystring | nullnullCollapses rows under headers of a shared value at this path.
collapsedstring[] | CollapseStateexpandAll()Which group headers are shut, two-way. A bare id list is the open mode with those ids shut.
editablebooleanfalseLets an editable cell open an editor.
editorFor(dataType) => ComponentType<CellEditorProps> | null / (dataType) => Component<CellEditorProps> | null—The editor a cell opens, by data type. A type it does not answer for opens FieldEditor.
editorPlacement'inline' | 'popover'per data typeWhere every cell editor opens. A column's own editorPlacement wins over it.
showCodebooleanfalseShow the programmatic field path beside the header's display name.
columnMenubooleanfalseA menu on every header: sort, hide and pin left.
columnPickerbooleanfalseA Columns button in the toolbar: the user adds, removes and reorders the type's fields, kept in this browser. Needs context.
columnsKeystringone per entity typeThe localStorage key the column choice is kept under.
pageSizesnumber[][25, 50, 100]Rows per page offered in the footer. pages only.
maxHeightstring'28rem'Height of the scrolling body.
virtualizeAfternumber100Rows above which the body is virtualised.
emptyLabelstring'No rows'Shown when the read returned nothing.
errorLabelstring—Shown in place of what the failed read said.
size'sm' | 'md' | 'lg''md'Row text and header height: text-xs/h-9, text-sm/h-10, text-base/h-11.
class / classNamestring—Merged after the widget's own classes.
sourcefrom CollectionControlEntitySourcerequiredThe rows, the filter, the sort and the page behind them.
paging'pages' | 'more' | 'scroll''pages'How the set is walked: a page number, a load-more row, or the scroller.
sortfrom CollectionControlSortSpec[]—The source's sort, two-way.
filtersfrom CollectionControlFilterNode | WireGroup | null—The source's filter, two-way.
selectionfrom CollectionControlEntityRef[][]The selected rows, two-way. Names loaded rows only.
getRowIdfrom CollectionControl(row) => string—How a row is keyed, in the DOM and in the selection. Default Type:id.
isRowDisabled(row) => boolean—True for a row that cannot be selected or edited.
loadingLabelfrom CollectionControlstring'Loading…'Names the skeletons a read stands behind, for a screen reader.
eventpayloadwhen
onColumnsChangeCollectionColumn[]A column was hidden from its header menu, or chosen in the column picker.
onCollapsedChangeCollapseStateA group header was opened or shut.
onSortChangeSortSpec[]The source's sort changed, from a header or from the prop.
onFiltersChangeFilterNode | WireGroup | nullThe source's filter changed.
onSelectionChangeEntityRef[]The selection changed.
slotreceivesdraws
toolbarStart—Left region of the toolbar above the table.
toolbarEnd—Right region of the toolbar above the table.
row{ row, id, index, selected, disabled, columns }Draws the cells of one row. The table keeps the row's box.
cell{ row, id, column, value, disabled }Draws one cell's contents. Ignored where row is given.
groupHeader{ value, column, count, expanded, id }Draws a group header's contents, after its chevron.

sort and filters mirror the source, so a SortPicker, a FilterBar and a ColumnPicker drop into the toolbar and none of them reaches into source.

<EntityTable
source={source}
columns={columns}
onColumnsChange={setColumns}
sort={toSortSpecs(sortKeys)}
filters={filter}
selection={selected}
onSelectionChange={setSelected}
toolbarStart={<FilterBar entityType="Version" context={context} facets={['sg_status_list']} value={filter} onValueChange={setFilter} />}
toolbarEnd={<SortPicker entityType="Version" context={context} value={sortKeys} onValueChange={setSortKeys} />}
/>

With columnPicker, a Columns button at the end of the toolbar opens a ColumnPicker over the entity type’s fields. The user adds, removes and reorders columns; the columns first passed are the defaults, and Reset columns puts them back. The choice is kept in localStorage under one key per entity type, or under columnsKey (a project and a type, say), and a later visit opens on it. A path the type no longer has is skipped. A column the source does not read yet is added to it with addFields, which reads the loaded rows again. Hiding a column from its header menu is kept the same way. In React the table writes through onColumnsChange, so a host holding columns in state passes both.

<EntityTable
source={source}
columns={columns}
onColumnsChange={setColumns}
context={context}
columnPicker
columnsKey={`shots:${projectId}`}
/>

A ColumnPicker can also sit in the toolbar on its own, bound to the same array the table holds: the two drive each other, and nothing is kept.

<EntityTable
source={source}
columns={columns}
onColumnsChange={setColumns}
toolbarStart={
<ColumnPicker
context={context}
entityType="Version"
value={columns.map((column) => column.path)}
onValueChange={pickColumns}
/>
}
/>

An editor is handed value, dataType, field, commit and cancel, and owns its own keys. Without editorFor, an editable cell opens the type’s own control from the field-editor item: a status cell opens StatusPicker, an entity cell EntityPicker and a multi-entity cell EntityMultiPicker, each reading through context. The cell editor takes projectId, precision, symbol and the context’s site preferences.

The editor opens in a popover anchored to the cell, carrying the field’s name, the control and Cancel and Save, so a cell’s width never squeezes it. A checkbox is one press and stays in the cell. editorPlacement on the table forces one or the other everywhere, and editorPlacement on a column spec forces it for that column.

With selectable, a press anywhere on a row toggles it, as a press on its checkbox does, and anchors there. Cmd or Ctrl with a press toggles one row as well. Shift with a press takes every row from the anchor to the one pressed, and selects or clears the range as the anchor went; a second Shift+press moves the far end rather than adding a second range. A press on a link, a button or an open editor inside a cell is left to it.

The header box adds or drops the loaded rows and keeps picks made on other pages; it reads indeterminate when some loaded rows are picked. onSelectAllMatching offers the rest of the set: once every loaded row is picked and the set holds more, a line above the table offers every matching row, and the host reads their ids and writes the selection. The table never reads past its own pages.

async function selectAllMatching() {
const refs = [];
for (let number = 1; ; number += 1) {
const page = await client.search('Version', { filters: source.filters, fields: ['id'], page: { size: 500, number } });
refs.push(...page.data.map((row) => ({ type: row.type, id: row.id })));
if (!page.hasMore) break;
}
setSelected(refs);
}

The selection model is core’s toggleRow, extendRange, extendByStep and setRefs, for a host holding a list of its own.

With columnMenu, every header carries a menu: sort ascending, sort descending, clear sort, hide column, and pin left. It is off by default: a header sorts on a press, and the column picker in the toolbar is where columns are shown and hidden. The sort entries are inert on a column the schema says cannot be sorted. Hiding writes the shorter column list back through columns. Pinning sticks the column to the start of the scrolling body.

keydoes
TabMoves through toolbar, header buttons, column menus, checkboxes, editable cells and the footer.
Enter, Space on a headerSorts by that column, ascending, then descending, then unsorted.
Enter, Space on a column menuOpens it; arrows walk it, Escape closes it.
Enter on an editable cellOpens the editor.
Enter in an editorCommits the value. In a textarea it adds a line, and Cmd or Ctrl with Enter commits.
Escape in an editorCancels and restores the value.
a press outside an editorCommits the value.
Enter, Escape, Save, Cancel in a popover editorThe same: Enter and Save commit, Escape and Cancel restore.
Space on a checkbox or a cellToggles the row, or every loaded row from the header.
Shift+ArrowDown, Shift+ArrowUp in the bodyMoves the cursor and carries the range from the anchor with it, selecting or clearing as the anchor went.
Cmd+A, Ctrl+A in the tableSelects every loaded row, keeping picks from other pages.
ArrowDown, ArrowUp in the bodyMoves the cursor down or up the same column, over the rows it can land on.
ArrowDown on the last loaded rowIn more and scroll, asks for the next page. The cursor stays where it is until the rows arrive, then lands on the first of them.
Enter in the page numberGoes to that page.
Tabfrom CollectionControlReaches the collection. It is one tab stop, and the cursor holds the focus inside it.
↑ ↓from CollectionControlMoves the cursor over the rows it can land on, never a disabled one, and brings it into view.
↓ on the last loaded rowfrom CollectionControlIn more and scroll, asks for the next page. The cursor waits where it is and lands on the first of the new rows.

An editable cell says so: it washes to bg-accent/50 under the pointer and under the keyboard cursor, and its tooltip reads “Double-click or press Enter to edit”. A double-click opens the editor too. A press inside a popup the editor opened — a calendar, a status list, a picker’s results — leaves the cell open. A header drags onto another to reorder, and its right edge drags to resize.

A disabled row takes no keys, no click and no checkbox, and none of its cells opens an editor.

Sorting is the server’s. A header click sets the source’s sort and reads the page again, because a sort applied to one loaded page orders the page and not the set. Sorts also fail silently where filters fail loudly: a field that cannot be sorted, and a name that does not exist, both return 200 with the rows in default order, so a sortable path is verified against the schema first (026_result_order).

The cursor in the body is the focused checkbox or editable cell: a row with neither is not a place a cursor can be.

Paging is the server’s too. No total is in a read: a paged GET answers data and links alone, and five spellings of a count option are accepted at 200 and change nothing, so pages mode walks the set with an explicit page number and stops on a short page rather than on a missing links.next, which is emitted on every page forever (006_pagination). The “of N” in the range comes from one _summarize call counting id, taken once per filter; a site that does not answer that key leaves the range reading “n to m” (020_summarize).

Grouping collapses the loaded rows under headers, and the group path leads the source’s sort so a group is not split across pages. A count is the rows loaded under that header, and a page that opens on the value the last header carries grows that group rather than opening a second one.

collapsed is a mode with exceptions rather than a list of ids, so Collapse all covers the headers the next page brings as well as the ones already loaded. A bare id list still works and reads as the open mode with those ids shut. collapseAll, expandAll, isCollapsed and toggleCollapsed in core are the readings over it, and a host holding the same state for a list of its own imports them.

An edit writes one field with PUT /entity/<type>/<id>, which changes only the keys it is given and leaves the rest alone (put_entity_type_id). The write answers the whole record but resolves no dotted path, so the row is read again afterwards (024_read_after_write). A dotted path is never editable: a write names one field of one row.

Column sizing, resizing, ordering and grouping are TanStack Table’s (@tanstack/table-core 9.2.4). The markup, the classes and the anatomy of the toolbar, the pagination footer and the column menu follow ReUI’s Base UI data grid (ReUI 2.5.2).