@simpleworkjs/frontend
v0.4.2
Published
Browser-side JavaScript package for SimpleWorkJS apps
Maintainers
Readme
@simpleworkjs/frontend
Browser-side JavaScript for SimpleWorkJS apps. It turns the backend's auto-generated REST API and OPTIONS schema endpoints into live, rendered UI with almost no per-page code.
Full API reference:
docs/app.mddocuments the entireappobject — every namespace, method, property, event topic, anddata-sw-*attribute. This README is the quick tour.
What it provides
Everything hangs off a global app object:
| Namespace | Purpose |
|-----------|---------|
| app.api | jQuery AJAX wrapper for the REST API. |
| app.model | Schema-aware model client (list/get/create/update/remove) with a schema cache. |
| app.pubsub | In-browser publish/subscribe bus (regex topic matching). |
| app.socket | Socket.IO client, wired into app.pubsub for live model events. |
| app.sync | Live updates: bridges incoming model:* events into refresh events, and binds a jq-repeat scope to a model with app.sync.bind(). |
| app.filter | Search/facet filtering for a jq-repeat scope, client-side or server-side. |
| app.notify | Notification bell, feed and desktop notifications, driven by the same model events. |
| app.render | Builds Bootstrap tables, cards, and forms from the schema. |
| app.messages | Contextual action messages, confirm dialogs, and toasts. |
| app.util | Small helpers (escapeHtml, formToObject, capitalize, uuid). |
| app.ready(cb) | Run a callback once the DOM and socket are ready. |
| $.fn.validate / $.validateSettings | [validate] attribute-driven client-side form validation (mirrors, doesn't replace, server-side checks). |
Loading the assets
When you use @simpleworkjs/backend, these files are served for you at /lib/js/, so include them directly:
<script src="/socket.io/socket.io.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/[email protected]/mustache.min.js"></script>
<script src="/lib/js/app.js"></script>
<script src="/lib/js/app.model.js"></script>
<script src="/lib/js/app.sync.js"></script>
<script src="/lib/js/app.filter.js"></script>
<script src="/lib/js/app.notify.js"></script>
<script src="/lib/js/app.render.js"></script>
<script src="/lib/js/app.custom.js"></script>
<script src="/lib/js/app.validate.js"></script>You can also resolve asset paths from Node (e.g. to bundle them yourself):
const frontend = require('@simpleworkjs/frontend');
console.log(frontend.assets.app); // .../@simpleworkjs/frontend/lib/app.jsapp.api
A thin promise-returning ($.ajax) wrapper. Paths are whatever your API mounts (e.g. /api/Task).
app.api.get('/api/Task', {done: false}); // GET, query params
app.api.options('/api/Task'); // OPTIONS -> schema
app.api.post('/api/Task', {title: 'Buy milk'});
app.api.put('/api/Task/' + id, {done: true});
app.api.del('/api/Task/' + id); // DELETE (note: del, not delete)The delete method is
app.api.del—deleteis a reserved word, so it is not used as a method name.
app.model
A higher-level client that fetches every model's schema on startup (via the API root + OPTIONS) and caches it. Prefer this over raw app.api when you want schema-aware helpers.
app.model.ready(function () { // fires once all schemas are loaded
const schema = app.model.schema('Task'); // cached OPTIONS response
app.model.list('Task', {done: false}); // -> {results: [...]}
app.model.get('Task', id); // -> {data: {...}}
app.model.create('Task', {title: 'New'});
app.model.update('Task', id, {done: true});
app.model.remove('Task', id);
app.model.relatedList('Project', id, 'tasks'); // hasMany association
});Schema shape
The OPTIONS response cached by app.model (and returned by the backend) looks like:
{
name: 'Task', // model name
pk: 'id', // primary-key field name
display: {...}, // model-level display hints (name, titleField, ...)
fields: { // per-field metadata (this is the field map)
title: {name: 'title', type: 'string', htmlType: 'text', display: {...}, ...},
// ...
},
paths: {...}, // REST paths
}app.render
Declaratively render a model into an element with data-sw-* attributes, then call app.render.build():
<div data-sw-model="Task" data-sw-mode="table"></div>
<div data-sw-model="Task" data-sw-mode="card"></div>
<div data-sw-model="Task" data-sw-mode="form" data-sw-pk="..."></div>app.model.ready(function () {
app.render.build('[data-sw-model]');
});data-sw-mode="table"— a Bootstrap table with View/Edit/Delete actions.data-sw-mode="card"— a card grid.data-sw-mode="form"— a create/edit form; supplydata-sw-pkto edit an existing record.
Rendered tables and forms are bound to live scopes, so they update automatically when app.sync reports a change. All rendered values are HTML-escaped.
app.pubsub and app.socket
app.socket is a Socket.IO client. When the backend emits a model event it is republished onto the pubsub bus under two topics:
model:<Model>:<action>— e.g.model:Task:create.model:any— every model event.
Topic patterns are matched as regular expressions:
const sub = app.pubsub.subscribe('model:Task:.*', function (data, topic) {
console.log(topic, data);
});
sub.remove(); // unsubscribeapp.sync
The sync layer is what makes a page update by itself when anyone — another user, a background job, or this tab — changes a record.
Listening
app.sync.on('Task', 'update', function (data) { /* ... */ });
app.sync.onAny(function (data) { /* every model change */ });app.sync.bind() — live updates for a hand-written table
app.render's generated views are live automatically. bind() gives the same
behaviour to a table you wrote yourself, without adopting the renderer:
app.sync.bind('hosts', 'Host', {
key: 'host', // pk field; defaults to the scope's jq-index-key
parse: hostParseRow, // server record -> row object
filter: fn(row), // optional: drop rows that don't belong in this list
fetch: fn(pk), // optional: re-fetch when an event carries no body
reveal: true, // scroll to + flash the changed row (default)
onChange: fn(), // called after the scope changes
});It patches the single changed row rather than reloading the list, so scroll position, checkbox selection and open dropdowns survive someone else's edit.
bind() normalizes both event dialects in use — the framework's
model:<Model>:<action> with a {model, action, pk, data} payload, and the
model:<Model>:<action>:<pk> form with a bare record — and works on either
app.pubsub or a plain app.subscribe bus. Returns {unbind()}.
app.filter
Search and facet filtering for a jq-repeat scope.
const filter = app.filter.bind('hosts', {
input: '#hostSearch', // search box
fields: ['host', 'ip', 'domain.provider'], // nested paths allowed
facets: {
ssl: function (row, value) { return value === 'any' || row.is_wildcard === (value === 'wildcard'); },
},
count: '#hostCount', // optional "3 of 40 shown" readout
threshold: 500, // switch to server mode above this
fetch: function (state) { ... }, // required for server mode
});
filter.set('ssl', 'wildcard');
filter.clear();The mode is chosen from the data rather than configured per view: a list
filters in the browser until it outgrows threshold, then queries the server
(debounced, with out-of-order responses discarded). Without a fetch it stays
client-side at any size rather than silently filtering nothing.
In client mode non-matching rows are hidden, not removed — clearing the search brings them straight back with no re-fetch.
app.filter.live() — filtering and live updates together
const {filter, sync} = app.filter.live('hosts', 'Host', {
input: '#hostSearch',
fields: ['host', 'ip'],
parse: hostParseRow,
});Wires the two together so a row arriving over the socket is shown only if it matches the filter that's active right now — and is still there, ready to appear, when the filter is cleared.
app.messages
Contextual UI feedback.
// Contextual action message anchored to an element:
app.messages.action('Saved successfully', $form, 'success');
// Confirm a destructive action (resolves to a boolean):
const ok = await app.messages.confirm('Delete this record?', $target, 'danger');
if (ok) await app.api.del('/api/Task/' + id);
// Page-wide toast:
app.messages.toast('Welcome back, admin.', 'info');app.util
app.util.escapeHtml(userInput); // always escape before injecting into HTML
app.util.formToObject($form); // serialize a form to a plain object
app.util.capitalize('task'); // 'Task'
app.util.uuid(); // v4 UUID$.fn.validate
Attribute-driven client-side form validation, mirroring (not replacing) your server-side checks:
<form>
<div class="form-group">
<input name="password" validate="password">
<b class="invalid-feedback"></b>
</div>
<div class="form-group">
<input name="confirm" validate="eq:password">
<b class="invalid-feedback"></b>
</div>
</form>$('form').on('submit', function(event){
if (!$(this).validate(event)) return; // event.preventDefault() already called
});
// or: $.validateInit(); // auto-wires every <form action="..."> on submitBuilt-in rules: eq:<fieldName> (must match another field), user (uid-style
identifier), password (length/character-class policy), ip (dotted-quad).
Register your own with $.validateSettings:
$.validateSettings({
rule: {
hostname: function(value){
if (!/^[a-z0-9.-]+$/i.test(value)) return 'Enter a valid hostname';
}
}
});A rule function returns a falsy value when the field is valid, or a message
string when it isn't; the message is written into the nearest
b.invalid-feedback and the field gets is-invalid/is-valid.
License
MIT
app.notify
Notifications, without a notification system.
A notification system's hard problem is "who should see this" — and the server's socket read gate already answers it, live, per row, every time an event goes out. So there is no recipient resolution and no fan-out: a notification is an event that reached you, and history is those same events replayed through the same gate.
app.notify.configure({
links: {
Host: (pk) => '/hosts/' + encodeURIComponent(pk),
Permission: () => '/permissions',
},
});Everything is optional:
| option | default | |
|---|---|---|
| endpoint | 'activity' | feed + watermark path, relative to app.api's base |
| links | {} | model -> (pk) => url; a model with no entry renders unlinked |
| collapseWindowMs | 60000 | how long same-model+action events collapse together |
| maxRows | 30 | rendered rows; history keeps everything the server returns |
Markup
Binds by id, so the shell owns the styling:
<div id="notify-bell" style="display:none">
<span id="notify-badge" style="display:none">0</span>
<a id="notify-desktop-toggle"></a>
<ul id="notify-list"></ul>
</div>The bell reveals itself once the feed loads — that proves both a session and the endpoint, and avoids depending on an app-specific "logged in" CSS class.
What the server must expose
GET <endpoint> -> {results: [{model, action, target, actor, created_on}], unread, seen_at}
PUT <endpoint>/seen <- {seen_at}Store the shape of each event, not its payload — model, action, pk, actor,
timestamp is all the feed renders, and not storing bodies means history never
becomes a second copy of your data retaining a deleted record's contents. Filter
the replay through the same read gate that decided live delivery.
"Unread" is one watermark per user rather than a read flag per item, so opening the bell on one device clears the badge on all of them.
Collapsing
One user action commonly writes several records — creating a resource in a
directory app emits eleven events, and a bulk import emits hundreds. The feed
groups same-model+action events inside collapseWindowMs and says "203
resources updated"; history keeps every row.
Desktop notifications
Uses the Web Notifications API, and stays out of the way: permission is only
ever requested from a click on #notify-desktop-toggle, and nothing fires
while the tab is focused — you are already looking at the page that just
updated itself. Repeats of the same model+action reuse the notification tag,
so a burst replaces rather than stacks.
