Skip to content

Releases: simpleworkjs/frontend

v0.5.0 - mute what you do not want to hear about

Choose a tag to compare

@wmantly wmantly released this 17 Sep 01:52
88efe45

The notification feed grows a way to turn itself down. It subscribed to every model event the socket delivered and showed all of them — right for a change log, wrong for a person.

Two bugs behind one symptom

I'm getting a lot of notifications at once.

A burst of events raised a burst of toasts.

const collapsed = collapse([event])[0];   // an array of exactly ONE event

collapse([event]) can never merge with anything — a no-op wrapper. Every arriving event popped its own toast and its own desktop notification, while the bell list collapsed correctly. The same sweep produced one tidy "42 resources updated" row and 42 stacked toasts beside it.

Events do not arrive one at a time: a status evaluator walks every resource and writes the ones that changed; a discovery poll touches last_seen on every guest. Popups now buffer for burstMs (900ms, reset on each arrival so a continuous stream reports once when it settles) and go through the same collapse() the list uses, capped at maxToastsPerBurst. burstMs: 0 restores the old behaviour.

And there was no filtering at all.

Muting

key meaning
'Resource:update' one model, one action
'Resource' one model, every action
'*:update' one action, every model — the "hide updates" button

A mute is a view, not a filter on what is recorded. Events keep arriving and keep being retained, so unmuting shows the history you had been ignoring rather than a gap — which is what makes muting safe to experiment with. A muted kind does not count toward unread and does not toast, because muting something that still nags has not really been muted.

Preferences persist per-app in localStorage; a corrupt or unavailable store degrades to "no mutes" rather than breaking the feed.

The filter UI

Rendered into #notify-filters when the shell provides it, and built from the models actually present in the feed rather than a fixed list: you filter the noise you can see, a model nobody emits never clutters the panel, and a muted model stays listed so the mute can be undone. Without that element, muting still works through configure({mutes}) and the mute/unmute/toggleMute/isMuted/mutes API.

config.titles

model -> (record) => string, for naming an event whose primary key is a UUID.

readableTarget() suppresses UUIDs — right, since a UUID in a sentence is noise — which left a UUID-keyed model rendering as a bare "access request added": no who, no what. Exactly the models where the pk is the least interesting thing about the record. Titles are escaped like any other text, and a throwing title function cannot take the feed down.

Upgrading

No breaking changes. burstMs defaults to 900ms, so toasts are now summarised rather than one-per-event; set burstMs: 0 to keep the old behaviour. Add <div id="notify-filters"></div> to your bell dropdown to get the filter UI.

118/118 (17 new). Full changelog: https://github.com/simpleworkjs/frontend/blob/v0.5.0/CHANGELOG.md

v0.4.3 — bound app.notify event history

Choose a tag to compare

@wmantly wmantly released this 16 Sep 01:30
6063bf4

Fixed

app.notify grew without bound and re-collapsed its whole history on every event.

The feed subscribes to every model event for the life of the tab and unshifted each one onto app.notify.events, which nothing ever trimmed — the existing cap was on rendered rows, not on retained events. The comment said so outright: "Cap on rendered rows. History keeps everything the server returns." That was written for a finite server response, not a live socket stream.

The worse half: push() calls render() on every event, and render() called collapse(this.events) over the entire array, allocating a fresh group object for every non-collapsing entry before discarding all but the first maxRows. So one arriving event cost O(history), on an array that only ever grew. With 100k events banked, each new event walks 100k entries and allocates ~100k objects.

Harmless on a quiet page. Not on one whose models churn on a timer: in theta-directory a Proxmox discovery poll rewrites last_seen on every discovered guest, and each write publishes model:Resource:update. A Directory tab left open accumulated events all day and was killed at 4GB.

Two changes:

  • events is capped at config.maxEvents, trimmed oldest-first on both the live path and the server feed.
  • collapse() takes a limit and stops once it has enough groups. Collapsing is strictly local — an event only ever merges into the group immediately before it — so rendered rows are byte-identical to a full walk.

The cap sits well above maxRows (30) on purpose, so collapse counts stay honest: the feed should say "203 resources updated", not "30". A burst longer than the cap undercounts at its tail, which is the right thing to lose. unread is unaffected — it still counts every event that arrived.

Added

  • app.notify config key maxEvents (default 1000): cap on events retained in memory, independent of maxRows.

Checks

96 tests pass, including three new ones: the cap holds under 5× overflow while keeping newest-first and preserving unread; the server feed is capped the same way as the live stream; and early-exit collapsing renders identical rows to a full walk (alternating models — the worst case for an off-by-one).

Measured over 20k events: events.length 20000 → 1000, push throughput 23.0s → 14.9s, with the gap widening as history grows.

Full Changelog: v0.4.2...v0.4.3

v0.4.2

Choose a tag to compare

@wmantly wmantly released this 15 Aug 01:01

Rich model icons, foreground live toasts, and feed action controls in app.notify.

v0.4.1

Choose a tag to compare

@wmantly wmantly released this 13 Aug 18:16
d5298bb

0.4.1

Fixed

  • app.messages.confirm(message, null, type) threw instead of showing a
    dialog.
    renderActionHtml called .closest() directly on whatever
    $target it was passed, so a page that calls confirm() with no inline
    .actionMessage target (a documented, commonly-used form — the caller
    passes null when it wants the toast fallback) crashed with Cannot read properties of null (reading 'closest') before the Confirm/Cancel buttons
    were ever wired up. $targetPassed is now normalized to an empty jQuery
    object when it isn't one already.
  • confirm() had no toast fallback at all — unlike action(), the
    "no .actionMessage on this page" path just logged a console warning and
    left the returned promise pending forever, so a confirm button on a page
    with no inline target looked like it silently did nothing (worse than the
    crash above once that was fixed on its own). confirm() now renders its
    dialog into a non-autohiding, non-dismissable toast in that case — same
    escalation action() already used for a plain message, extended to host
    interactive buttons.

v0.4.0

Choose a tag to compare

@wmantly wmantly released this 12 Aug 20:14
004d2a7

Added

  • app.notify: a notification bell, feed and desktop notifications, driven
    by the model events the framework already publishes. There is no separate
    notification stream and no recipient resolution — a notification system's hard
    problem is "who should see this", and the server's socket read gate already
    answers it live, per row. A notification is an event that reached you, and
    history is those events replayed through the same gate.
    • Configurable per app via app.notify.configure({links, endpoint, collapseWindowMs, maxRows}). links maps a model to a URL builder so
      clicking a notification lands on the record that changed.
    • Collapses same-model+action events inside a time window. This is not
      cosmetic: one user action commonly writes several records — creating a
      resource in a directory app emits eleven events, a bulk import emits
      hundreds — so an uncollapsed feed is unusable. History keeps every row.
    • Reads both event dialects, like app.sync: the framework's self-describing
      {model, action, pk, data} and the topic-carried
      model:<Model>:<action>:<pk> with a bare record. Written for one alone, the
      feed is silently dead in an app using the other — the socket delivers events
      and the badge never moves.
    • Desktop notifications via the Web Notifications API: permission is only ever
      requested from a click, nothing fires while the tab is focused, and repeats
      of the same model+action reuse the tag so a burst replaces rather than
      stacks.
    • The bell reveals itself once the feed loads, rather than depending on an
      app-specific "logged in" CSS class — those have a habit of diverging between
      apps, and the bell then silently never appears.
    • init() is idempotent: a shell may call it after configure(), and it also
      runs on ready. Subscribing twice would count every event twice and fire two
      desktop notifications for one change.
    • Placeholder actors (__NONE__, written by a record created and never
      updated) are not reported as the person who did it.
    • A UUID primary key is left out of the wording, since it reads as noise, but
      the row keeps its link to the record.

v0.3.1

Choose a tag to compare

@wmantly wmantly released this 12 Aug 07:16
6e69afd

Three fixes, all found by driving 0.3.0 in a real browser rather than a test DOM.

Fixed

  • Filtered rows carrying a Bootstrap display utility were never actually
    hidden.
    app.filter used jQuery's .hide(), which writes a plain inline
    display: none — and that loses to .d-flex / .d-block / .d-grid,
    declared !important in Bootstrap's stylesheet. Any list-group row, card
    grid cell or flex row stayed on screen while the filter's own count
    correctly reported it as filtered out. Hiding now writes an !important
    inline rule and restores the element's previous inline display on the way
    back, so it falls back to its class exactly as before.
  • The count denominator went stale. A view that reports its server total
    via setTotal() kept that number as the denominator forever, so a row
    arriving live (or being deleted) produced readouts like "6 of 4 shown". In
    client mode the browser holds the whole set, so the scope length is the
    total; setTotal now only fixes the denominator in server mode, where the
    browser really does hold just one page.
  • A wrong pk in an event topic could duplicate a row. app.sync.bind()
    trusted the topic's pk over the record's own key. Publishers get this wrong:
    theta42's ModelPs published the class name as the pk for every model
    keyed on name, because a class always has a built-in .name. The lookup
    then matched nothing and appended a second copy of the record on every
    update. The record is now authoritative about its own identity, with the
    topic pk as the fallback (still used for deletes, which carry no body).

v0.3.0

Choose a tag to compare

@wmantly wmantly released this 12 Aug 06:44
a7aa0a0

Added

  • app.sync.bind(scope, model, options): live updates for a table you
    wrote yourself. Previously the only way to get a self-updating list was to
    build it with app.render; any hand-written jq-repeat view had to
    re-implement the listen/parse/patch dance per page, so in practice most
    pages simply never updated. bind() needs nothing but a jq-repeat scope
    and a pubsub bus — no app.render, no app.model, no generated REST
    routes. Options: key, parse, filter, fetch, reveal, onChange.
    Returns {unbind()}.

    • Patches the one changed row instead of reloading the list, so scroll
      position, checkbox selection and open dropdowns survive another user's
      edit — and one person's change doesn't cost every other viewer a full
      refetch.
    • Normalizes both event dialects in circulation: the framework's
      model:<Model>:<action> with a {model, action, pk, data} payload, and
      the model:<Model>:<action>:<pk> form carrying a bare record. Works on
      either an app.pubsub bus or a plain app.subscribe one, so the same
      file drops into apps that load only part of this package.
    • Always looks rows up by explicit key. jq-repeat reads a lone numeric
      argument as a positional index, so any model with an integer primary key
      would otherwise patch row 5 rather than the record with id 5.
    • A pk containing : (LDAP DNs, IPv6 literals) is rejoined rather than
      truncated at the first colon.
  • app.filter: search and facet filtering for a jq-repeat scope, with
    bind(scope, options) and live(scope, model, options).

    • Chooses client- or server-side from the data rather than per-view config:
      a list filters in the browser until it outgrows threshold, then queries
      the server (debounced, discarding out-of-order responses). The same view
      is a 12-row table on one install and a 12,000-row table on another, and
      neither should need a code change. With no fetch it stays client-side at
      any size rather than silently filtering nothing.
    • Client mode hides non-matching rows instead of removing them, so clearing
      the search restores them instantly with no refetch and no DOM rebuild.
    • Search spans several fields, including nested paths (domain.provider),
      case-insensitively; facets add named predicates that AND with it.
      Optional count element renders a "3 of 40 shown" readout.
    • live() wires filtering and live sync together: a row arriving over the
      socket appears only if it matches the filter that's currently active, and
      is still present — ready to show — once that filter is cleared.

Testing

  • Integration tests run against the real jq-repeat (added as a
    devDependency) rather than a scope stand-in. A stub can agree with a wrong
    assumption; the library cannot — this is what caught the numeric-primary-key
    bug above.
  • npm test now runs the whole test/ directory (it previously matched
    nothing and exited non-zero).

v0.2.7

Choose a tag to compare

@wmantly wmantly released this 30 Jul 01:24
541dea2

Added

  • app.util.revealItem(el): scrolls a just-added/-edited element into view (scrollIntoView({behavior: 'smooth', block: 'center'})) and flashes its background using a Bootstrap 5 CSS variable, fading back to normal. Accepts a jQuery object or a raw DOM node (e.g. jq-repeat's item.__jq_$el) — the standard "show the user where their change landed" behavior for post-create/edit list refreshes.

v0.2.6

Choose a tag to compare

@wmantly wmantly released this 28 Jul 21:10
ccd2766

Added

  • app.modal standardized entity-modal support: optional tabs (nav-tabs + tab-content, matching the pattern already used for proxy's host modal), optional footer (metaHtml left / buttonsHtml right — the footer element is entirely absent when omitted, and any stale footer from a previous open() call is removed so it can't leak into a later bare caller), and optional url (pushes a linkable path on open, restores the prior path on close, and closes the modal on browser Back/Forward via popstate instead of re-pushing history). All three are opt-in — existing {title, bodyHtml, size, onShown} callers are unaffected.
  • app.modal.showTab(id), app.modal.on(event, selector, handler) (a delegated binding that survives open()'s per-call DOM rebuild), app.modal.deepLinkSlug(basePath), app.modal.formatAudit(record, {formatDate}), and app.modal.footerButtons({onSave, saveLabel, closeLabel, extraHtml}).