RESTful table
Summary
An editable CRUD table bound to a REST endpoint: GET to load,
POST to create, PUT to update, DELETE to remove. Entries must
carry an id; server responses may echo the stored
entry, and when they do the echoed values win — so
server-computed fields come back into the row. Layers on
.soma-table: click a row
to edit it inline, the footer row creates, per-row actions
appear on hover or keyboard focus.
When to use
| Situation | Guidance |
|---|---|
| Admin CRUD over a flat resource | The home turf. Devices, users, mappings: small records edited field-by-field against a REST endpoint. |
| Read-only data | Use a plain or sortable table — no endpoint wiring needed. (Or keep this component with the write switches off, as below, if the data source is already REST.) |
| Records with many fields or validation flow | Use a dialog or dedicated page with a form; inline rows suit a handful of short columns. |
Examples
Endpoint contract
The component speaks plain fetch with JSON both
ways (Content-Type and Accept:
application/json). All URLs come from
resources:
| Operation | Request | Response handling |
|---|---|---|
| Load list | GET resources.all | JSON array of entries. Alternatively all is a fn(callback): call back with the array and no HTTP happens at all. |
| Create | POST resources.self, body = the create row's values | May echo the stored entry (typically adding the id); echoed fields win. |
| Update | PUT resources.self/{id}, body = the FULL entry with edited values merged in | May echo the stored entry; echoed fields win. |
| Remove | DELETE resources.self/{id} | Response body ignored. |
Failure semantics: a non-2xx on the list GET (the initial load
or a .reload()) renders a
"Failed to load" row with the status message; a failed write
(POST/PUT/DELETE) leaves the form row in place with nothing
applied locally. The operator's input is not lost, and no
event fires. Empty response bodies are fine (they parse to
null — no echo).
Server-echo semantics
After a create or update the row is rebuilt from
{ …localValues, …serverEcho }: whatever the
server returns overrides what was typed. That is how a
created entry gets its server-assigned id
(without it, edit and delete cannot target the row), and how
server-computed fields (normalised names, timestamps,
derived state) land back in the table without a reload. An
endpoint that echoes nothing works too; the table then shows
the values as typed.
Keyboard
| Key / interaction | Action |
|---|---|
| Click a row (or its Edit button) | Opens the inline edit form; the first editable input is focused. A row already in edit mode ignores further clicks. |
| Enter in a form-row input | Submits: Update for an edit row, Add for the create row. |
| Esc in an edit row | Cancels and restores the read row. (The create row has no cancel; it simply stays.) |
| Tab into a row | Row action buttons are revealed on keyboard focus (:focus-within) exactly as on hover — nothing is mouse-only. |
HTML
Author an empty table. The component builds header, rows and forms (there is nothing else to write by hand):
<table id="devices"></table>
<script>
Soma.restfulTable('#devices', {
resources: {
all: '/api/devices', // GET list — a URL, or fn(callback)
self: '/api/devices', // base for POST / PUT/{id} / DELETE/{id}
},
columns: [
{ id: 'name', header: 'Name' },
{ id: 'ip', header: 'IP address', allowEdit: false },
{ id: 'state', header: 'State',
readView: (value) => `<span class="soma-badge">${value}</span>` },
],
allowCreate: true, allowEdit: true, allowDelete: true,
});
</script>
CSS classes
All added by the component alongside .soma-table; listed for theming reference.
| Class | Effect |
|---|---|
.soma-restfultable | Marks the enhanced table. |
.soma-restfultable-allowhover | Added when edit or delete is enabled. Row action buttons appear on hover or keyboard focus. |
.soma-restfultable-editable | On rows that open the edit form on click (pointer cursor + hover tint). |
.soma-restfultable-operations | The trailing actions column (Edit / Delete, Update / Cancel). Its header carries assistive-only text. |
.soma-restfultable-editing / -create | An inline form row: an entry being edited; the create row (always in the table footer). |
.soma-restfultable-loading | Loading state while the list is fetched (spinner + message). |
.soma-restfultable-no-entries | Empty-state row when the list comes back empty (also hosts the failed-to-load message). |
JavaScript
Constructor + options
| Member | Description |
|---|---|
Soma.restfulTable(el, options) | Get or create the singleton for a table element / selector; builds the table and loads the list immediately. Throws when the selector matches nothing, when options.columns is missing, or when options.resources.all is missing. |
resources.all | Required. List source: a URL for GET, or fn(callback) that calls back with the entries array (any transport, auth, or envelope-unwrapping happens inside your function). |
resources.self | Base URL for writes: POST self creates, PUT self/{id} updates, DELETE self/{id} removes. Needed whenever any write switch is on. |
columns | Required. Array of {id, header?, allowEdit?, readView?}; see the per-column rows below. |
columns[].id | The entry field this column reads and writes. |
columns[].header | Header text; defaults to the id when omitted. |
columns[].allowEdit | false keeps the column read-only in EDIT rows (its current value shows as text). The create row still takes an input for every column — a new entry needs all its fields once. |
columns[].readView | (value, entry) => html — customises display output (badges, links, formatting). The return value is raw HTML: escape anything user-supplied. Without it, values render as text (safe by default). |
allowCreate / allowEdit / allowDelete | Feature switches, all default true: the create row, click-to-edit, per-row Delete. All three false = a read-only view over the endpoint. |
autoFocus | Default false. When true, focus returns to the create row's first input after an entry is added, for keying in several records back-to-back. |
createPosition | 'bottom' (default) or 'top': where a newly created entry is inserted in the list. The create form row itself always sits in the table footer. |
noEntriesMsg / loadingMsg | Override the empty-state and loading texts; default to the localised restfulTable.noEntries / restfulTable.loading strings. |
Instance methods
| Member | Description |
|---|---|
.reload() | Re-fetch the list (shows the loading row, re-renders, re-emits initialized). Async — resolves to the instance. |
.getEntries() | A copy of the current entries array (mutating it does not touch the table). |
.destroy() | Empty the table element, remove the .soma-restfultable / -allowhover classes and drop the singleton. (The .soma-table base class added at init stays.) |
Events
| Event | Fires | detail |
|---|---|---|
soma-restfultable-initialized | After every successful list load (the initial one and each .reload()). | { table } |
soma-restfultable-row-added | After a successful create (server echo already merged). | { entry, table } |
soma-restfultable-row-updated | After a successful update (server echo already merged). | { entry, table } |
soma-restfultable-row-removed | After a successful delete. | { entry, table } |
All four are bubbling CustomEvents dispatched on the table
element. This component has no .on() helper —
listen with plain addEventListener, on the table
or delegated higher up:
const table = document.querySelector('#devices');
table.addEventListener('soma-restfultable-row-added', (e) => {
Soma.toast({ body: `Created "${e.detail.entry.name}"`, appearance: 'success' });
});
// The events bubble — one document-level listener can audit every table:
document.addEventListener('soma-restfultable-row-removed', (e) => {
console.log('removed', e.detail.entry.id, 'from', e.target.id);
});
resources.all as a function (for auth headers,
envelope unwrapping, or a non-HTTP source):
Soma.restfulTable('#devices', {
resources: {
all: (callback) => {
fetch('/api/devices?site=prague', { headers: { Authorization: token } })
.then((res) => res.json())
.then((page) => callback(page.items)); // hand back the entries ARRAY
},
self: '/api/devices',
},
columns: [{ id: 'name', header: 'Device' }],
});
Tuning the create flow — top insertion, focus retention, and custom empty text (the "Rapid entry" demo above):
Soma.restfulTable('#tags', {
resources: { all: '/api/tags', self: '/api/tags' },
columns: [
{ id: 'name', header: 'Tag' },
{ id: 'color', header: 'Color' },
],
createPosition: 'top', // new entries land first in the list
autoFocus: true, // focus returns to the create row after each add
noEntriesMsg: 'No tags yet — add the first below.',
});
The live examples above, exactly as this page wires them (the fetch stub stands in for a real REST backend):
// Demo backend: an in-memory store behind a fetch stub — the component
// speaks plain fetch, so point `resources` at your real endpoint instead.
const store = [
{ id: 1, name: 'switch-core-1', ip: '10.0.0.11', site: 'Seattle', state: 'healthy' },
{ id: 2, name: 'switch-edge-4', ip: '10.0.0.24', site: 'Denver', state: 'degraded' },
];
// … window.fetch stub answering GET/POST/PUT/DELETE on /api/devices
// and /api/tags …
// readView output is raw HTML — escape anything user-supplied.
const esc = (s) => {
const div = document.createElement('div');
div.textContent = s == null ? '' : String(s);
return div.innerHTML;
};
const STATE_BADGE = {
healthy: 'soma-badge-success',
degraded: 'soma-badge-warning',
failing: 'soma-badge-danger',
};
Soma.restfulTable('#rt-demo', {
resources: { all: '/api/devices', self: '/api/devices' },
columns: [
{ id: 'name', header: 'Device' },
{ id: 'ip', header: 'IP address' },
{ id: 'site', header: 'Site', allowEdit: false },
{ id: 'state', header: 'State',
readView: (value) => `<span class="soma-badge ${STATE_BADGE[value] || ''}">${esc(value)}</span>` },
],
});
// Rapid entry: create at the top, keep focus in the create row.
Soma.restfulTable('#rt-top', {
resources: { all: '/api/tags', self: '/api/tags' },
columns: [
{ id: 'name', header: 'Tag' },
{ id: 'color', header: 'Color' },
],
createPosition: 'top',
autoFocus: true,
noEntriesMsg: 'No tags yet — add the first below.',
});
// Read-only variant: same component, all write switches off; the list
// comes from fn(callback) instead of a URL.
const ro = Soma.restfulTable('#rt-readonly', {
resources: { all: (cb) => cb(store) },
columns: [
{ id: 'name', header: 'Device' },
{ id: 'ip', header: 'IP address' },
{ id: 'site', header: 'Site' },
{ id: 'state', header: 'State' },
],
allowCreate: false, allowEdit: false, allowDelete: false,
});
document.querySelector('#rt-reload').addEventListener('click', () => ro.reload());
Localisation
The generated button labels (Edit, Delete, Update, Add,
Cancel), the assistive operations-column header and the
default loading/empty messages are localised via
Soma.i18n: the restfulTable.edit /
.delete / .update / .add /
.cancel / .operations /
.loading / .noEntries keys. Override
strings (or switch locale) BEFORE the table initialises —
strings are read at render time:
Soma.i18n({
'restfulTable.add': 'Create',
'restfulTable.noEntries': 'Nothing here yet',
});
Soma.restfulTable('#devices', { /* … */ });
See i18n for locale packs and the full
API. Per-instance, noEntriesMsg /
loadingMsg win over the localised defaults.