Skip to content
simpleworkjsPublic

About

Browser-side JavaScript package for SimpleWorkJS apps

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

36 Commits

Folders and files

Repository files navigation

@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.md documents the entire app object — every namespace, method, property, event topic, and data-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/mustache@4.2.0/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.js

app.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 — delete is 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; supply data-sw-pk to 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(); // unsubscribe

app.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 submit

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

About

Browser-side JavaScript package for SimpleWorkJS apps

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages