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

SituationGuidance
Admin CRUD over a flat resourceThe home turf. Devices, users, mappings: small records edited field-by-field against a REST endpoint.
Read-only dataUse 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 flowUse a dialog or dedicated page with a form; inline rows suit a handful of short columns.

Examples

Live CRUD

Inline edit against a REST endpoint (demo uses an in-memory fetch stub):

Click a row to edit it; the footer row creates. Row actions appear on hover or keyboard focus. Enter submits, Escape cancels. The State column is rendered through a readView, a badge instead of plain text; Site opts out of editing with allowEdit: false.

Rapid entry

createPosition: 'top' + autoFocus — new entries land first, focus stays in the create row:

Starts empty to show a custom noEntriesMsg. Add a tag and press Enter: the entry is inserted at the TOP of the list and focus returns to the create row's first input, so several records go in back-to-back.

Read-only

allowCreate / allowEdit / allowDelete: false (same component, display only):

This instance loads its list via fn(callback) from the same in-memory store. Edit above, then Reload (.reload()) to see the changes land here.

Endpoint contract

The component speaks plain fetch with JSON both ways (Content-Type and Accept: application/json). All URLs come from resources:

OperationRequestResponse handling
Load listGET resources.allJSON array of entries. Alternatively all is a fn(callback): call back with the array and no HTTP happens at all.
CreatePOST resources.self, body = the create row's valuesMay echo the stored entry (typically adding the id); echoed fields win.
UpdatePUT resources.self/{id}, body = the FULL entry with edited values merged inMay echo the stored entry; echoed fields win.
RemoveDELETE 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 / interactionAction
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 inputSubmits: Update for an edit row, Add for the create row.
Esc in an edit rowCancels and restores the read row. (The create row has no cancel; it simply stays.)
Tab into a rowRow 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.

ClassEffect
.soma-restfultableMarks the enhanced table.
.soma-restfultable-allowhoverAdded when edit or delete is enabled. Row action buttons appear on hover or keyboard focus.
.soma-restfultable-editableOn rows that open the edit form on click (pointer cursor + hover tint).
.soma-restfultable-operationsThe trailing actions column (Edit / Delete, Update / Cancel). Its header carries assistive-only text.
.soma-restfultable-editing / -createAn inline form row: an entry being edited; the create row (always in the table footer).
.soma-restfultable-loadingLoading state while the list is fetched (spinner + message).
.soma-restfultable-no-entriesEmpty-state row when the list comes back empty (also hosts the failed-to-load message).

JavaScript

Constructor + options

MemberDescription
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.allRequired. 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.selfBase URL for writes: POST self creates, PUT self/{id} updates, DELETE self/{id} removes. Needed whenever any write switch is on.
columnsRequired. Array of {id, header?, allowEdit?, readView?}; see the per-column rows below.
columns[].idThe entry field this column reads and writes.
columns[].headerHeader text; defaults to the id when omitted.
columns[].allowEditfalse 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 / allowDeleteFeature switches, all default true: the create row, click-to-edit, per-row Delete. All three false = a read-only view over the endpoint.
autoFocusDefault 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 / loadingMsgOverride the empty-state and loading texts; default to the localised restfulTable.noEntries / restfulTable.loading strings.

Instance methods

MemberDescription
.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

EventFiresdetail
soma-restfultable-initializedAfter every successful list load (the initial one and each .reload()).{ table }
soma-restfultable-row-addedAfter a successful create (server echo already merged).{ entry, table }
soma-restfultable-row-updatedAfter a successful update (server echo already merged).{ entry, table }
soma-restfultable-row-removedAfter 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.