Releases: simpleworkjs/frontend
Release list
v0.5.0 - mute what you do not want to hear about
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 eventcollapse([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
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:
eventsis capped atconfig.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.notifyconfig keymaxEvents(default1000): cap on events retained in memory, independent ofmaxRows.
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
v0.4.1
0.4.1
Fixed
app.messages.confirm(message, null, type)threw instead of showing a
dialog.renderActionHtmlcalled.closest()directly on whatever
$targetit was passed, so a page that callsconfirm()with no inline
.actionMessagetarget (a documented, commonly-used form — the caller
passesnullwhen it wants the toast fallback) crashed withCannot read properties of null (reading 'closest')before the Confirm/Cancel buttons
were ever wired up.$targetPassedis now normalized to an empty jQuery
object when it isn't one already.confirm()had no toast fallback at all — unlikeaction(), the
"no.actionMessageon 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
escalationaction()already used for a plain message, extended to host
interactive buttons.
v0.4.0
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}).linksmaps 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 thetagso 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 afterconfigure(), 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.
- Configurable per app via
v0.3.1
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.filterused jQuery's.hide(), which writes a plain inline
display: none— and that loses to.d-flex/.d-block/.d-grid,
declared!importantin 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
viasetTotal()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;setTotalnow 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'sModelPspublished the class name as the pk for every model
keyed onname, 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
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 withapp.render; any hand-writtenjq-repeatview had to
re-implement the listen/parse/patch dance per page, so in practice most
pages simply never updated.bind()needs nothing but ajq-repeatscope
and a pubsub bus — noapp.render, noapp.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
themodel:<Model>:<action>:<pk>form carrying a bare record. Works on
either anapp.pubsubbus or a plainapp.subscribeone, so the same
file drops into apps that load only part of this package. - Always looks rows up by explicit key.
jq-repeatreads 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.
- Patches the one changed row instead of reloading the list, so scroll
-
app.filter: search and facet filtering for ajq-repeatscope, with
bind(scope, options)andlive(scope, model, options).- Chooses client- or server-side from the data rather than per-view config:
a list filters in the browser until it outgrowsthreshold, 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 nofetchit 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;facetsadd named predicates that AND with it.
Optionalcountelement 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.
- Chooses client- or server-side from the data rather than per-view config:
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 testnow runs the wholetest/directory (it previously matched
nothing and exited non-zero).
v0.2.7
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'sitem.__jq_$el) — the standard "show the user where their change landed" behavior for post-create/edit list refreshes.
v0.2.6
Added
app.modalstandardized entity-modal support: optionaltabs(nav-tabs + tab-content, matching the pattern already used for proxy's host modal), optionalfooter(metaHtmlleft /buttonsHtmlright — the footer element is entirely absent when omitted, and any stale footer from a previousopen()call is removed so it can't leak into a later bare caller), and optionalurl(pushes a linkable path on open, restores the prior path on close, and closes the modal on browser Back/Forward viapopstateinstead 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 survivesopen()'s per-call DOM rebuild),app.modal.deepLinkSlug(basePath),app.modal.formatAudit(record, {formatDate}), andapp.modal.footerButtons({onSave, saveLabel, closeLabel, extraHtml}).