UI Drafter

Architecture
Desktop-like SPA

Eric Fortis

UI Drafter is a single-page application that:


For continuously saving, there are three general options:

  • Option 1: Collecting how to reproduce each action.
  • Option 2: Saving a complete snapshot after every action.
  • Option 3: A hybrid, such as saving on immutable data structures, such as Immutable.js.

We'll discuss the first one as it's the most versatile and simple.

Reproducing Actions

Let's call Undo Frame to the data needed for replaying an action. For example, the following frame updates the title field of a Card with a certain id.

[
    [BASE.setCard, 'idABC', CF.title, 'The New Title']
]

This way undo is a matter of reverting to a previous snapshot, and replaying each change up to the penultimate.

Some undo steps have many actions. For example, moving an Entry to another Card.

UI Drafter Moving an Entry to Another Card

Behind the scenes that function has a removeEntry('cardA', …) and an insertEntry('cardB', …). Therefore, we use a transaction to collect all the calls to the underlying BASE setters into a single undo frame.

function moveEntryToAnotherCard(fromCardId, entryId, toCardId) {
    const endTx = TransactionRecorder();
    // …
    _removeEntry(fromCardId, entryId);
    _insertEntry(toCardId, …);
    endTx();
}

Cons

There's an important part missing in the previous snippet. How to copy the properties of the removed Entry?

In contrast to an immutable data structure (Option 3), we can't simply edit non-primitives, such as objects and arrays. Instead, we have to create a new one. Otherwise, the edit would inadvertently change them in the undo stack too.

For example, outputLinks is an array of objects (Entry Pointers). So to move an Entry to another Card, we have to deep clone that array, and assign it to the newly inserted Entry.

UI Drafter an Entry with many output links to share a UI constraint

Pros

The undo frames could also be used for synchronizing multiple users in real-time (not implemented in UI Drafter). Also, they're extendable with metadata. For example, for squashing many undo steps into one, like a burst of a slider input.

UI Drafter Fixed Decimals slider input field

Data Flow

The Backend is not to be confused with the server-side. In the diagram, everything is client-side.


localStorage File System IndexedDB UI States Memory Frontend Compress Decompress Sanitize File Data Memory Backend API Transactions Engine Memory Middleware

Backend API

The backend memory holds the end-user File. Its data is JSON-safe, and it's saved automatically in the browser's IndexedDB after every change (Option 2). Therefore, if there are many tabs with the same file open, the one on the last edited tab wins. Also, the corresponding undo stack is saved there too, so it can be restored across sessions for unlimited undo levels.

UI Drafter doesn't save to the server-side, but that wouldn't be too different from saving to the local file system.

By convention, these files are under **/api directories. For example:

  • app/api/BaseSetters.js
  • card/api/CardGetters.js
  • card/api/internal/_updateCardTitle.js

Compressor

The compressor is mainly for removing the default Card and Entry fields. You can see that compressed JSON file by clicking File → Save.

Sanitization

To prevent prototype pollution attacks, all the constructors (File, Card, Entry) are instantiated with their defaults, and sealed with Object.seal, before assigning or merging new properties.

Type Checks

Example 1: isCard(cardId) more than type-checking, it validates that the Card exists.

function _updateCardTitle(cardId, title) {
  isCard(cardId);
  isString(title);
  setCard(cardId, CF.title, title);
}

Example 2: The number of decimal digits validator verifies that it's in range [0-15], in addition to the integer check.

Middleware (Business Layer)

The middleware files are the "app-specific" algorithms. I think of them as the engine, or as the fun algorithms to write. The rule is that they don't query or need the UI; they're are exclusively fed from the backend. By convention, their extension is *.mid.js. Here are some examples:

connectors/
locateConnectorPoints.mid.js

It's used for drawing the connections, and for finding the Entries within a marquee region.

UI Drafter Marquee Selecting Entries

numeric/
computeAllCards.mid.js

Determines the dependency order for computing formulas and evaluating user-JavaScript. For example, Nested Cards are like parentheses, so the deepest ones get computed first.

UI Drafter Showing Computing Graph Hierarchy

links/
LoopDetection.mid.js

The loop detection algorithms prevent creating connections that would cause infinite loops. For example, when trying to link a total that depends on another Card into that Card.

UI Drafter Showing how infinite loops get prevented

Memory

The middleware engine has some memory objects too. For example, for the connector points, and caching payload relevant fields for Cards with JavaScript code.

Frontend

These are the UI components, for example:

  • card/Card.js
  • toolbar/PreviewButton.js

The frontend memory objects, UI States, are non-undoable actions. Some of them are persisted to the browser's localStorage, like toolbarIsVisible and scrollbarsAreVisible. On the other hand, some non-persisted states are:

  • editMenuIsOpen
  • selectedEntries
UI Drafter UI States Showing Edit Menu and Multi-selection of Entries

React.js

React-wise, the state-controlled components need the static getDerivedStateFromProps(props, state) method in order to restore a previous value when undoing, and for real-time updates.

End

I hope this guide helps you to organize and wire up your SPA. Don't forget to check out UI Drafter. There's a free, live version at free.uidrafter.com

Sponsored by: