Real-time CRUD guide: Front end (part 1)

4,215 reads

For this guide, we will be using https://github.com/SocketCluster/sc-crud-sample to go through various concepts of real-time CRUD. For part 1, we will only consider the front end of our sc-crud-sample application.

To implement real-time CRUD, we need the following things:

A database (in this case RethinkDB).

A WebSocket server + client framework which supports pub/sub (SocketCluster).

A back end module to process CRUD requests and publish change notifications (sc-crud-rethink).

A front end model component to represent a single resource from the database (SCModel).

A front end collection component to represent a collection of resources from the database (SCCollection).

A reactive front end framework to render the data from model and collection components (VueJS)

For the sc-crud-sample app, one of the goals was to keep the front end as simple as possible so that it could work without bundling tools or compile steps. You may still want to bundle all your front end scripts together when serving the app in production (e.g. to speed up loading and to support old browsers) but not having to depend on a bundler (e.g. WebPack) during development means that you can iterate more quickly when coding your front end. Also, if you want to switch to HTTP2 now or in the future (e.g. to push assets to clients without latency), then you may decide that you don’t need a bundler altogether.

The main index.html looks like this:

^ Here we’re just importing a CSS stylesheet and defining some global styles inline. If our app was bigger, we’d probably want to keep all our CSS style definitions in separate files but for now we chose to keep it simple.

In the above definition, we have a tag <div id="app"></div> — Vue will replace this tag with our app-inventory component; this component is defined inside our script which we import via the <script src="/app-inventory.js" type="module"></script> tag. The app-inventory component is the entry point of our entire VueJS front end logic. Note that we use the ES6 modules syntax to import dependencies within our code.

Note that for our application scripts, we try to use paths relative to the root directory; this makes it easier to find things when reading through various parts of the code.

Our app-inventory.js module looks like this:

^ This file contains all the logic required to bootstrap our front end application. At the top, we’re importing some scripts/functions which will allow us to generate all the different pages within our app.

let socket = window.socket = socketCluster.create();

^ This creates a WebSocket-based socketcluster-client socket object which our SCModel and SCCollection components will use to load data from and send data to our server and through which those components will receive change notifications when data changes on the server side.

let pageOptions = {

socket

};



let PageCategoryList = getCategoryListPageComponent(pageOptions);

let PageCategoryDetails = getCategoryDetailsPageComponent(pageOptions);

let PageProductDetails = getProductDetailsPageComponent(pageOptions);

let PageLogin = getLoginPageComponent(pageOptions);

^ Here we’re constructing all the VueJS page components which are used in our app. We’re passing a reference to our socket to every page component so that they can bind their own SCModel and SCCollection components to it. We share a single global socket object with all our components in the same way that we would share a single global store between all components if we were following the Redux architecture — In our case, this approach allows multiple components to share the same pub/sub channels to consume real-time data from the server.

let routes = [

{

path: '/category/:categoryId/product/:productId',

component: PageProductDetails,

props: true

},

{

path: '/category/:categoryId',

component: PageCategoryDetails,

props: true

},

{

path: '/',

component: PageCategoryList,

props: true

}

];



let router = new VueRouter({

routes

});

^ We define all the routes in our app and link them to the relevant page components; we pass everything to VueRouter and let it do its magic.

Everything is pretty standard VueJS boilerplate. The most interesting part is at the bottom of the script:

template: `

<div>

<div v-if="isAuthenticated" style="padding: 10px;">

<router-view></router-view>

</div>

<div v-if="!isAuthenticated" style="padding: 10px;">

<page-login></page-login>

</div>

</div>

`

^ This means that if isAuthenticated is true, VueJS will render our router (which will render the relevant page based on the current URL path). If isAuthenticated is false, VueJS will show the user with a login page from which they will be able to login. SocketCluster supports JWT authentication, so we will listen for an authStateChange event on the socket to check when it becomes authenticated; we will update our Vue app component’s isAuthenticated property to reflect the authState of our socket. This is the important part:

created: function () {

this.isAuthenticated = this.isSocketAuthenticated();

socket.on('authStateChange', () => {

this.isAuthenticated = this.isSocketAuthenticated();

});

...

Then there is some ugly code just below it…

...

this._localStorageAuthHandler = (change) => {

// In case the user logged in from a different tab

if (change.key === socket.options.authTokenName) {

if (this.isAuthenticated) {

if (!change.newValue) {

socket.deauthenticate();

}

} else if (change.newValue) {

socket.authenticate(change.newValue);

}

}

};

window.addEventListener(

'storage',

this._localStorageAuthHandler

);

^ That’s just to make authentication work across multiple browser tabs; so in our case, if the user has our app open in multiple tabs and they log into (or log out of) either one of those tabs, then we want the authentication state of our app to update immediately to match across both open tabs.

The page-login.js module looks like this:

^ The reason why we export a getPageComponent function is to allow us to receive the global socket object from our upstream app component.

The most important code in this component is:

socket.emit('login', details, (err, failure) => {

if (err) {

this.error = 'Failed to login due to error: ' + err;

} else if (failure) {

this.error = failure;

} else {

this.error = '';

}

});

^ This will attempt to authenticate our client socket. The login event will be handled by our custom server-side logic; it will check if the username and password combination is correct and, if so, our client socket will emit an authStateChange event; if you recall our logic (and template) in app-inventory , this event will cause VueJS to hide our login page and activate our Vue router component.

Once the router becomes activated, it will render one of our page components depending on our the current URL. All our pages are relatively similar in their basic structure so we will analyse the most advanced one which is the component in page-category-details.js ; this page represents a detailed view of a Category which contains a bunch of Product resources. In our app, all Product resources are associated with a Category ; this allows us to filter items based on which group they belong to.

This is what the category details screen looks like (URL http://localhost:8000/#/category/:categoryId ):

This page contains two tables which are lists of Product resources. The table at the top represents a list of all Product resources which belong to the current category (with Prev page and Next page links to navigate through all products). The second table underneath only lists up to 5 products which belong to the current category AND which are about to run out of stock soon (have the lowest qty). Both of these tables update in real-time as new products are added and as the qty and price of the products change — The best way to test this is to open the app in multiple browser tabs and watch the changes instantly sync between them.

The first text input box on the page lets you add new products to the current category. The second text input box lets you change the threshold qty for the second table so that you can reduce it down to the items that have the smallest qty — This feature shows that you can generate complex real-time views based on multiple parameters.

The code for the page-category-details.js component looks like this:

At the very beginning, we import our SCCollection and SCModel components. The next most interesting part in this code is this:

props: {

categoryId: String

},

^ This categoryId comes from VueRouter which reads it from the URL. If our URL looks like this: http://localhost:8000/#/category/4dfbfe47-d2fe-4e12-9ba1-26c8877221e8 , then our categoryId will be the string "4dfbfe47-d2fe-4e12-9ba1-26c8877221e8" .

The next most important part in our code is this:

data: function () {

this.categoryModel = new SCModel({

socket: pageOptions.socket,

type: 'Category',

id: this.categoryId,

fields: ['name', 'desc']

});

...

^ This is where some of the real-time updating magic happens — Here we’re creating a Category model instance; this front end object will use the socket object to essentially bind itself to the relevant resource on the server and will make sure that it always shows the latest data. The SCModel also exposes methods to update the underlying resource on the server side.

The type property here refers to the table name inside our RethinkDB database, the id is the id of the resource in the database (RethinkDB uses uuids ). The fields property is an array of fields that we want to include as part of this model — the underlying resource may have many more fields; so a bit like with GraphQL, we only specify the ones which we need for the current page.

The following code is where we instantiate our main SCCollection which will provide data to the table at the top of the page:

this.productsCollection = new SCCollection({

socket: pageOptions.socket,

type: 'Product',

fields: ['name', 'qty', 'price'],

view: 'categoryView',

viewParams: {category: this.categoryId},

pageOffset: 0,

pageSize: 5,

getCount: true

});

^ Like in SCModel , the type property refers to the table name in RethinkDB, also as before, the fields property is an array of fields that we want to include as part of this collection.

The view property holds the name of the view which this collection represents; a view is just a transformation applied to a collection (e.g. a filtered/sorted subset). In our case, the categoryView represents a subset of the Product collection which only contains products whose category field matches the current categoryId of our page.

The viewParams option holds an object which contains parameters which we pass to our view generator on the back end; the name of the properties of the viewParams object must match a field name on the underlying resource (in this case the Product resource has a category field). The productsCollection is relatively simple because it only has a single viewParams property — That said, it’s possible to have collections without any viewParams ; it’s also possible to have a collection without views; in that case, the collection component will just bind to the entire database table without any filtering.

The pageOffset option refers to the offset index from which to start loading the collection (starting at the nth item). The pageSize defines the maximum number of items that the collection can contain at any given time. The getCount property is a boolean which indicates whether or not we should load metadata about the total number of resources in the collection.

The next collection which is declared in our component is the lowStockProductsCollection :

this.lowStockProductsCollection = new SCCollection({

socket: pageOptions.socket,

type: 'Product',

fields: ['name', 'qty', 'price'],

view: 'lowStockView',

viewParams: {category: this.categoryId, qty: lowStockThreshold},

viewPrimaryKeys: ['category'],

pageOffset: 0,

pageSize: 5,

getCount: false

});

^ This SCCollection provides the data for the second table on the category details page. It looks very similar to the productsCollection except for a few properties. The first difference is that it uses a different view — lowStockView instead of categoryView ; also it has one additional viewParams : a qty property. The qty property allows us to adjust the threshold used to transform/filter our lowStockView .

Another major difference is that the lowStockView defines a viewPrimaryKeys array which contains 'category' as the only primary key; this is required because real-time filtered views rely on the fact that some equality relationships between resources exist within the view (e.g. All products which belong to the same categoryView have the same value as their category property). Since in this case we’re modeling an inequality relationship (e.g. in this case, our view represents a relationship where Product.qty is less than lowStockThreshold ), we cannot include it as part of our view’s primary key; we still get some performance benefits from having category as our primary key (better than no primary key).

By default, the viewPrimaryKeys property is optional; if not provided, all the properties in viewParams will be used as primary keys — being explicit about the primary keys allows us to meet the equality requirement for optimum real-time updates on collections. Important note: If primaryKeys are specified for a view in your back end schema (in worker.js ), then you must also specify matching viewPrimaryKeys for your collection on the front end.

Note that you can pass an empty array as viewPrimaryKeys ; this will guarantee that automatic real-time updates of the collection will work but the downside is that this approach will cause the collection to subscribe to every change notification on the entire table (instead of only a small affected subset as is the case for categoryView ) — the category primary key acts as a natural sharding mechanism; the more categories there are, the more efficiently change notifications will be delivered to users.

For the back end guide, see: Real-time CRUD guide: Back end (part 2)

Tags