Skip to content

This is a short tutorial to set up dhtmlx (dhx) using only JSON and node/mongodb as a back-end and the REST API. Recently I followed the 'Your First App' tutorial and felt unsatisfied with what I've learned. So I decided to push a little harder and use a demo app 'CRM System' as the base and create my own tutorial.

Notifications You must be signed in to change notification settings

rkristelijn/dhtmlx-json-node

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

68 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dhtmlx-json-node

Introduction

This is a short tutorial to set up dhtmlx (dhx) using only JSON and node/mongodb as a back-end and the REST API. Recently I followed the 'Your First App' tutorial and felt unsatisfied with what I've learned. So I decided to push a little harder and use a demo app 'CRM System' as the base and create my own tutorial.

Update: 20-JUN-2018: this project focuses on getting the framework up and running, interaction with different components and creating and connecting to the back-end. It doesn't concern optimizing the front-end in terms of code overview, readeability, systainability for changes and so on, but this project does using DHTMLX.com

DHTMLX Optimus is a micro framework for building DHTMLX-based apps. The framework enforces consistent application structure by breaking a monolithic application into a set of reusable classes. As a result, each part of an app can be developed and tested independently and used in various combinations. DHTMLX Optimus is a fully client-side solution. There aren't any special requirements to the server. You can use any data REST backend (PHP, Nodejs, .Net, Java, etc.)

This project only makes the proposed ['CRM System'] demo(https://dhtmlx.com/docs/products/demoApps/dhtmlxCRMSystem/index.html) work end-to-end, using fetch, and a REST API using Node.

Contents


Why?

back to Contents

Why you ask? DHTMLX (dhx for short) is a beautiful library to quickly ramp up CMS, CRM, HR, etc systems that are intuitive and complete. Probably you can use alternatives such as angular/vue/react material, but that comes with a full framework. This time I just wanted the simplicity of dhx itself.

DHTMLX API references:

Layout

Accordion Carousel Layout Popup Tabbar Windows
icon icon icon icon icon icon

Data Components

Chart DataView Grid List TreeGrid TreeView
icon icon icon icon icon icon

Form-oriented Components

Calendar ColorPicker Combo Editor Form Slider
icon icon icon icon icon icon

Navigation Components

Menu Ribbon Sidebar Toolbar
icon icon icon icon

When connecting the back-end to it (in English: make the new, delete button work, and allow updates that stay) I discovered that the default dhtmlxDataProcessor is unpredictable and heavy to use. @see my other project

When sending data from the browser, these are the options you have:

But now, the replacement of XMLHttpRequest is here: fetch and you can use it out of the box

So, this example uses:

Example

back to Contents

Below you will find screenshots of the demo app 'CRM System' only it runs from my Raspberry Pi (@see tutorial to set up).

Screenshot of dhtmlx with a working grid that changes also the form when you click a record

Screenshot of dhtmlx with the projects tile opened showing a grid, a graph and detail view

Screenshot of dhtmlx with the event tile opened, showing tiles of events

Screenshot of dhtmlx with the settings tile opened showing tiles and a form

Benchmark

back to Contents

Sample application

  • 53 requests
  • 385 KB transferred
  • Finish 5.87s
  • DOMContentLoaded: 337ms
  • Load: 376
  • Location: Internet (San Francisco, California, United States)
  • Full-blown webserver I presume: nginx/1.1.19
  • Static content
  • Uses local dhtmlx.js and dhtmlx.css

Demo application loaded from cache where possible

This application

  • 34 requests (17 less)
  • 81.8 KB transferred (4,7 times smaller)
  • Finish 1.08s (5 times faster)
  • DOMContentLoaded: n/a
  • Load: n/a
  • Location: LAN
  • Raspberry Pi 3 using Express in Node
  • Data from MongoDB
  • Ability to Edit, Add and Delete
  • Uses CDN to get dhtmlx.js and dhtmlx.css

This application loading from cache where possible

Installation

back to Contents

  1. git clone https://github.com/rkristelijn/dhtmlx-json-node.git
  2. cd dhtmlx-json-node
  3. npm i
  4. npm run seed / ctrl+c when done
  5. npm start
  6. point your browser at localhost:3000

Tutorial

back to Contents

Now follows every single step I took in ramping up my app.

Prerequisites:

  1. latest node and npm
  2. mongodb server up and running
  3. up-to-date javascript, git, bash and mongodb skills

You could have a look at setting up such an environment on either a physical raspberry pi or a virtual raspberry pi.

In this tutorial I consider: less is more, if we can use defaults, we should do it (default: index.html, index.js etc). Only use stuff if and when we need it. I try to follow the latest standards, like lambdas, HTML5, WCAG, OWASP, etc. Also I try to teach only once: the right way. I think it is better to learn things the right way and later discover; 'hey... there is also a old or wrong way'.

This tutorial follows my development step by step using git branches. Every chapter contains a link to the feature branch.

Plan

back to Contents

Not part of tutorial

  • Connect other views projects, events and settings
  • Clean up code
    • big global of the settingsforms can be taken out of the code
  • Optimize dataProcessor (moved to app.js and reused)
  • Optimize front-end (vue.js?)
    • Enable routes, so when pressing F5 just stays on the same view
  • Extend features
    • so sales, settings, events can be added, changed and deleted
    • dates are datepickers
    • multiselects, dropdowns
  • implement error handling
  • References

Step 1: Static node web server

back to top

@see branch step 2

Use npm to create a new package.json file:

npm init

  • package name: (default)
  • version: 0.0.1
  • description: Basic application using dhtmlx, json, node, rest
  • entry point: index.js
  • test command: (empty)
  • git repo: (default)
  • keywords: dhtmlx, json, node, rest
  • author: Remi Kristelijn
  • licence: (default: ISC)
  • Ok?: yes

Before we start coding, we need express as our middleware.

npm i --save express

This created the node_modules folder that we need to add to the .gitignore file:

echo node_modules > .gitignore

Then we create the entry point index.js

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send("<h1>Hello World</h1>");
});

app.listen(3000, () => {
  console.log('listening on *:3000');
});

That is all you need to create a simple node app. You can either use node . to fire up your application and point your browser to http://localhost:3000

Let's save our work using git add., git commit -a -m "blablabla" and git push

Step 2: Get dhx up and running

back to top

@see branch step 2)

Instead of sending a string, we will send a static html page.

A few notes;

  • as of August 2016 dhx provides a CDN so we don't have to download the javascript files.
  • WCAG dictates a page should have a language

Create index.html:

<!DOCTYPE html>
<html lang="en">

<head>
  <link rel="stylesheet" type="text/css" href="//cdn.dhtmlx.com/edge/dhtmlx.css">
  <script src="//cdn.dhtmlx.com/edge/dhtmlx.js"></script>
</head>

<body>
  <script type="text/javascript">
    dhtmlxEvent(window, "load", function () {
      dhtmlx.message({ type: "alert", text: "Hello world" });
    });
  </script>
</body>

</html>

Update index.js:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  // step2: instead of sending a string, we send a file
  res.sendFile(__dirname + '/index.html');
});

app.listen(3000, () => {
  console.log('listening on *:3000');
});

Restart your node application and enjoy the majesty.

Screenshot of dhtmlx with only a modal dialogue saying hello world

Step 3: Initialize the layout, grid and form with static data

back to top

@see branch step 3)

Best practices is to not mix html, javascript, css, etc together, so in this case, we are writing our dhtmlx app inline of index.html. Let's fix that. Before we do that, we need to create folder that is able to load our static files. We call this folder public.

  • mkdir public
  • mv index.html public

Update index.js

const express = require('express');
const app = express();
const path = require('path'); // step 3: we need to add path in order to use it

app.use('/', express.static(path.join(__dirname, 'public'))); // step 3: just serve up the full public directory

app.listen(3000, () => {
  console.log('listening on *:3000');
});
  • type node . just to make sure we didn't break anything.

Screenshot of dhtmlx with only a modal dialogue saying hello world

Now it is time to extract the javascript from index.html.

<!DOCTYPE html>
<html lang="en">

<head>
  <link rel="stylesheet" type="text/css" href="//cdn.dhtmlx.com/edge/dhtmlx.css">
  <script src="//cdn.dhtmlx.com/edge/dhtmlx.js"></script>
</head>

<body>
  <script type="text/javascript" src="app.js"></script>
</body>

</html>

Create a new file public/app.js

dhtmlxEvent(window, "load", function () {
  dhtmlx.message({ type: "alert", text: "Hello world" });
});

Save and restart the server

Screenshot of dhtmlx with only a modal dialogue saying hello world, network tab expanded showing app.js loading

Isn't that sweet.

Before we continue we probably are tired of restarting the server. Let's fix that using nodemon.

npm i --save-dev nodemon

We don't need to install globally as we can access the nodemon executables from the npm script.

Update package.json, add a start-script and add an ignore for the public folder:

{
  "//<!--...":"...-->",

  "scripts": {
    "start": "nodemon ./index.js --ignore public/",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

  "//<!--...":"...-->",

  "dependencies": {
    "express": "^4.16.3"
  },
  "devDependencies": {
    "nodemon": "^1.17.5"
  }
}

Now we are going to create the sidebar and the menu. It seems the demo app 'CRM System' uses a different skin.

Update index.html:

<!DOCTYPE html>
<html lang="en">

<head>
  <link rel="stylesheet" type="text/css" href="//cdn.dhtmlx.com/edge/skins/web/dhtmlx.css">
  <script src="//cdn.dhtmlx.com/edge/dhtmlx.js"></script>
  <link rel="stylesheet" type="text/css" href="style.css">
</head>

<body>
  <script type="text/javascript" src="app.js"></script>
</body>

</html>

Also create public/style.css

html, body {
	width: 100%;
	height: 100%;
	margin: 0px;
	overflow: hidden;
}

Then we need to create all elements, update public/app.js

var mainSidebar;
var mainToolbar;

function appInit() {
  mainSidebar = new dhtmlXSideBar({
    parent: document.body,
    icons_path: "imgs/sidebar/",
    width: 180,
    template: "tiles",
    items: [
      { id: "contacts", text: "Contacts", icon: "contacts.png" },
      { id: "projects", text: "Projects", icon: "projects.png" },
      { id: "events", text: "Events", icon: "events.png" },
      { id: "settings", text: "Settings", icon: "settings.png" }
    ]
  });

  mainToolbar = mainSidebar.attachToolbar({
    icons_size: 32,
    icons_path: "imgs/toolbar/",
    items: [
      { type: "text", id: "title", text: "&nbsp;" },
      { type: "spacer" },
      { type: "button", id: "add", img: "add.png" },
      { type: "button", id: "save", img: "save.png" }
    ]
  });

  mainSidebar.attachEvent("onSelect", function (id) {
    // todo, below doesn't look right; HTML in javascript
    mainToolbar.setItemText("title", window.dhx4.template("<span style='font-weight: bold; font-size: 14px;'>#text#</span>", { text: mainSidebar.cells(id).getText().text }));
  });

  // select 'Contacts' by default
  mainSidebar.cells("contacts").setActive(true);
}

dhtmlxEvent(window, "load", appInit);

Refresh and this is what we get:

Screenshot of dhtmlx a sidebar with 4 items and a toolbar with two buttons

Now the demo uses different js files for the 4 options in the menu and this is initialized on start. We combine the file to improve performance to have only one js file downloaded. Also the demo uses Google maps, we just ignore this feature for now as we need API keys in order for it to work. Also we need tons of images, data files etc. We combine this into one app. Next to that we need to remove the loader from the codebase.

The application looks now like this, and no console errors.

Screenshot of dhtmlx with a working grid that changes also the form when you click a record

Screenshot of dhtmlx with the projects tile opened showing a grid, a graph and detail view

Screenshot of dhtmlx with the event tile opened, showing tiles of events

Screenshot of dhtmlx with the settings tile opened showing tiles and a form

Step 3 a: Change XML to JSON

back to top

@see branch Step3a1

JSON is much more lightweight than XML.

pi@raspberry:~/dhtmlx-json-node/public/server $ ls -al
4364 contacts-minified.json
4445 contacts-ndjson.json
5639 contacts-minified.xml
6475 contacts-fully-beautified.json
6588 contacts-fully-beautified.xml

I'm only drawing conclusions on the minified versions, this is an improvement of (5639-4364) is 1275 bytes less (23% smaller). Let alone having to encode binary data using base64. It is the same for JSON, however there is BSON

Steps to convert dhx XML to JSON:

  1. use XML to JSON to convert data
  2. replace "-width" with "width", same for id, type, align, sort -> by replacing "- for "
  3. replace "#text" with "value"
  4. repoace "cell" with "data"
  5. replace "#cdata-section" with "value"
  6. remove rows level on top, remove colums level in head, rename row to rows below the header

Now we need to update the function calls to 'eat' JSON instead of XML. The weird thing is that every dhx object seems to need a different structure.

Old Code:

// ... CONTACTS
contactsGrid.load(A.server + "contacts.xml?type=" + A.deviceType, function () {
  contactsGrid.selectRow(0, true);
});
// ... PROJECTS
projectsGrid.load(A.server + "projects.xml?type=" + A.deviceType, function () {
  projectsGrid.selectRow(0, true);
});
// ... EVENTS
eventsDataView.load(A.server + "events.xml?type=" + A.deviceType);

// ... SETTINGS
settingsDataView.load(A.server + "settings.xml?type=" + A.deviceType, function () {
  settingsDataView.select("contacts");
});

New Code:

// ... CONTACTS
contactsGrid.load(A.server + "contacts.json?type=" + A.deviceType, function () {
  contactsGrid.selectRow(0, true);
}, "json");

// ... PROJECTS
projectsGrid.load(A.server + "projects.json?type=" + A.deviceType, function () {
  projectsGrid.selectRow(0, true);
}, "json");

// ... EVENS
eventsDataView.load(A.server + "events.json?type=" + A.deviceType, "json");

// ... SETTINGS
// load the data, somehow a callback doesn't work
settingsDataView.load(A.server + "settings.json", "json");
// fires when the data loading is finished and a component or data is rendered
settingsDataView.attachEvent("onXLE", function () {
  settingsDataView.select("contacts");
});

Step 4: Create and connect REST API

back to top

@see branch Step4

Step 4a: set up GET for contacts with a static file

The first thing we need to do is create a router set for every element.

index.js

const express = require('express');
const app = express();
const path = require('path');

const port = 3000;

app.use('/', express.static(path.join(__dirname, 'public')));

/// /* step 4
const apiRouter = require('./api/api-router')();
app.use('/api', apiRouter);
/// step 4 */

app.listen(port, () => {
  console.log('listening on *:' + port);
});

Then we create a new file and put some HATEOAS in.

api\api-router.js

const express = require('express');

let routes = () => {
  let apiRouter = express.Router();

  apiRouter.get('/', (req, res) => {
    res.json({
      contacts: { links: `${req.protocol}://${req.headers.host}/api/contacts` }
    });
  });

  return apiRouter;
}

module.exports = routes;

Now we have this:

Screenshot of json HATEOAS

I use JSON viewer to beautify.

Then we add an API for the contacts. Therefore we need to

  • move /public/server/contacts.json to /api/contacts/contacts.json

Add api/contact/contacts-router.js

const express = require('express');

let routes = () => {
  let contactsRouter = express.Router();

  contactsRouter.get('/', (req, res) => {
    res.sendFile(__dirname + '/contacts.json');
  });

  return contactsRouter;
}

module.exports = routes;

And update /api/api-router.js

const express = require('express');
const contactsRouter = require('./contacts/contacts-router')(); // add this line

let routes = () => {
  let apiRouter = express.Router();

  apiRouter.use('/contacts', contactsRouter); // add this line

  apiRouter.get('/', (req, res) => {
    res.json({
      contacts: { links: `${req.protocol}://${req.headers.host}/api/contacts` }
    });
  });

  return apiRouter;
}

module.exports = routes;

Finally we need tell the front-end that the entrypoint for getting contacts is moved.

public/app.js

contactsGrid.load("api/contacts?type=" + A.deviceType, function () {
  contactsGrid.selectRow(0, true);
}, "json");

The result:

Screenshot of json HATEOAS

Step 4b: set up GET for projects with a static file

back to top

Same as for contacts;

  • we create a folder in api called projects,
  • move the file public/server/projects.json to api/projects
  • create a new file api/projects/projects-router.js
  • add the route to api/api-router.js
  • point the load event to /api/projects

api/projects/projects-router.js

const express = require('express');

let routes = () => {
  let projectsRouter = express.Router();

  projectsRouter.get('/', (req, res) => {
    res.sendFile(__dirname + '/projects.json');
  });

  return projectsRouter;
}

module.exports = routes;

api/api-router.js

const express = require('express');
const contactsRouter = require('./contacts/contacts-router')();
const projectsRouter = require('./projects/projects-router')(); // add this line

let routes = () => {
  let apiRouter = express.Router();

  apiRouter.use('/contacts', contactsRouter);
  apiRouter.use('/projects', projectsRouter); // add this line

  apiRouter.get('/', (req, res) => {
    res.json({
      contacts: { links: `${req.protocol}://${req.headers.host}/api/contacts` },
      projects: { links: `${req.protocol}://${req.headers.host}/api/projects` }  // add this line
    });
  });

  return apiRouter;
}

module.exports = routes;

public/app.js

projectsGrid.load("api/projects?type=" + A.deviceType, function () {
  projectsGrid.selectRow(0, true);
}, "json");

Step 4c: events and settings

back to top

Repeat the process of projects step4b for events and contacts.

Step 4d: GET using data from db

For setting up a connection to the database, we need mongoose.

npm i --save mongoose

Update index.js, add:

//...
const mongoose = require('mongoose');
//...
mongoose.connect('mongodb://localhost/cms');
mongoose.set('debug', true);
let db = mongoose.connection;

db.on('error', console.error.bind(console, 'Mongoose:'));
db.once('open', () => {
  console.log('Connected to mongoose');
});
//...

If you now start your app:

[nodemon] starting `node ./index.js`
listening on *:3000
Connected to mongoose

(Hands in the air and say: Huray!)

Now we are going to create some data. We do this only once and we (mis)use the spec file for this. Later on I may want to write test. Yeah I know, test-drive development... I bother about test later, and maybe I'll start a new tutor where I first write my tests.

So first, let there be a Schema:

api/contacts/contacts-model.js

const mongoose = require('mongoose');

const ContactsSchema = mongoose.Schema({
  created: {
    type: Date,
    default: Date.now
  },
  photo: String,
  name: String,
  dob: Date,
  pos: String,
  email: String,
  phone: String,
  company: String,
  info: String
});

const Contact = mongoose.model('Contact', ContactsSchema);

module.exports = Contact;

Then create a small file to just insert one contact:

api/contacts/contacts-model.spec.js

const mongoose = require('mongoose');
const Contact = require('./contacts-model');

mongoose.connect('mongodb://localhost/cms');
mongoose.set('debug', true);
let db = mongoose.connection;

db.on('error', console.error.bind(console, 'Mongoose:'));
db.once('open', () => {
  console.log('Connected to mongoose');
});

contact = new Contact({
  photo: "<img src=\"imgs/contacts/small/margaret-black.jpg\" border=\"0\" class=\"contact_photo\">",
  name: "Margaret Black",
  dob: "9/1/1985",
  pos: "CEO",
  email: "mblack_ceo@mail.com",
  phone: "1-805-287-4750",
  info: "M Black Ltd"
});

contact.save(err => {
  if (err) {
    console.log('error', err);
  }
});

And now let's try it;

pi@raspberry:~/dhtmlx-json-node/api/contacts $ node contacts-model.spec.js 
Connected to mongoose
Mongoose: contacts.insertOne({ _id: ObjectId("5b0ea66a948a28613642d50f"), photo: '<img src="imgs/contacts/small/margaret-black.jpg" border="0" class="contact_photo">', name: 'Margaret Black', dob: new Date("Sat, 31 Aug 1985 22:00:00 GMT"), pos: 'CEO', email: 'mblack_ceo@mail.com', phone: '1-805-287-4750', info: 'M Black Ltd', created: new Date("Wed, 30 May 2018 13:26:02 GMT"), __v: 0 })

The only thing we need to do now is read the contacts.json file, loop though the data and create all contacts.

contacts-model.spec.js

const mongoose = require('mongoose');
const Contact = require('./contacts-model');
const fs = require('fs');

mongoose.connect('mongodb://localhost/cms');
mongoose.set('debug', true);
let db = mongoose.connection;

db.on('error', console.error.bind(console, 'Mongoose:'));
db.once('open', () => {
  console.log('Connected to mongoose');
});

fs.readFile('./contacts.json', (err, data) => {
  if (err) console.log('error', err);
  obj = JSON.parse(data);
  for (contact of obj.rows) {
    console.log(`Creating ${contact.data[1]}...`);
    contact = new Contact({
      photo: contact.data[0],
      name: contact.data[1],
      dob: contact.data[2],
      pos: contact.data[3],
      email: contact.data[4],
      phone: contact.data[5],
      company: contact.data[6],
      info: contact.data[7]
    });

    contact.save(err => {
      if (err) {
        console.log('error', err);
      }
    });
  }
});

Let's run it: node contacts-model.spec.js but first, empty the table to make sure all data is clean

pi@raspberry:~/dhtmlx-json-node/api/contacts $ mongo
MongoDB shell version: 3.2.11
connecting to: test
Server has startup warnings: 
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] 
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] ** WARNING: This 32-bit MongoDB binary is deprecated
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] 
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] 
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] ** NOTE: This is a 32 bit MongoDB binary.
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] **       32 bit builds are limited to less than 2GB of data (or less with --journal).
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] **       See http://dochub.mongodb.org/core/32bit
2018-06-04T11:12:23.300+0200 I CONTROL  [initandlisten] 
> use cms
switched to db cms
> db.contacts.remove({})
WriteResult({ "nRemoved" : 17 })
> exit
bye
pi@raspberry:~/dhtmlx-json-node/api/contacts $ node contacts-model.spec.js 
Creating Margaret Black...
Creating John Woken...
Creating Jake Peterson...
Creating Bill Jackson...
Creating Jennifer Miles...
Creating Cortny Barrens...
Creating Edward Eden...
Creating Andrew Scott...
Creating Steve Anderson...
Creating Jane Wilson...
Creating Alan Robbinson...
Creating William Parson...
Creating Charlotte Wolks...
Creating Pamela Worner...
Creating Ralf Ross...
Creating Dan Witley...
Creating Anna Harrison...
Connected to mongoose
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf1"), photo: '<img src="imgs/contacts/small/margaret-black.jpg" border="0" class="contact_photo">', name: 'Margaret Black', dob: '9/1/1985', pos: 'CEO', email: 'mblack_ceo@mail.com', phone: '1-805-287-4750', company: 'M Black Ltd', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf2"), photo: '<img src="imgs/contacts/small/john-woken.jpg" border="0" class="contact_photo">', name: 'John Woken', dob: '3/24/1987', pos: 'Business analyst', email: 'john.woken@mail.com', phone: '1-867-777-9834', company: 'M-Black Ltd', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf3"), photo: '<img src="imgs/contacts/small/jake-peterson.jpg" border="0" class="contact_photo">', name: 'Jake Peterson', dob: '11/27/1982', pos: 'Accountant', email: 'jake.peterson@mail.com', phone: '1-845-257-9751', company: 'Jackson and partners Inc', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf4"), photo: '<img src="imgs/contacts/small/bill-jackson.jpg" border="0" class="contact_photo">', name: 'Bill Jackson', dob: '5/3/1980', pos: 'Web developer', email: 'Bill.Jackson@mail.com', phone: '1-874-548-9751', company: 'BFG Consulting Inc', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf5"), photo: '<img src="imgs/contacts/small/jennifer-miles.jpg" border="0" class="contact_photo">', name: 'Jennifer Miles', dob: '4/17/1985', pos: 'Project manager', email: 'Jennifer.Miles@mail.com', phone: '1-852-895-9752', company: 'F&M Ltd', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf6"), photo: '<img src="imgs/contacts/small/cortny-barrens.jpg" border="0" class="contact_photo">', name: 'Cortny Barrens', dob: '6/20/1979', pos: 'Sales manager', email: 'Cortny.Barrens@mail.com', phone: '1-842-458-1452', company: 'F&M Ltd', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf7"), photo: '<img src="imgs/contacts/small/edward-eden.jpg" border="0" class="contact_photo">', name: 'Edward Eden', dob: '1/14/1983', pos: 'Business analyst', email: 'Edward.Eden@mail.com', phone: '1-863-452-4750', company: 'BFG Consulting Inc', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf8"), photo: '<img src="imgs/contacts/small/andrew-scott.jpg" border="0" class="contact_photo">', name: 'Andrew Scott', dob: '2/15/1980', pos: 'HR manager', email: 'Andrew.Scott@mail.com', phone: '1-874-452-4873', company: 'Jackson and partners Inc', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bf9"), photo: '<img src="imgs/contacts/small/steve-anderson.jpg" border="0" class="contact_photo">', name: 'Steve Anderson', dob: '4/17/1978', pos: 'Business analyst', email: 'Steve.Anderson@mail.com', phone: '1-863-548-4874', company: 'Bank of LA', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bfa"), photo: '<img src="imgs/contacts/small/jane-wilson.jpg" border="0" class="contact_photo">', name: 'Jane Wilson', dob: '6/25/1980', pos: 'Product manager', email: 'Jane.Wilson@mail.com', phone: '1-863-452-9834', company: 'HDF Insurance', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bfb"), photo: '<img src="imgs/contacts/small/alan-robbinson.jpg" border="0" class="contact_photo">', name: 'Alan Robbinson', dob: '8/16/1970', pos: 'Web developer', email: 'Alan.Robbinson@mail.com', phone: '1-863-452-9752', company: 'Meriton Group', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bfc"), photo: '<img src="imgs/contacts/small/william-parson.jpg" border="0" class="contact_photo">', name: 'William Parson', dob: '10/2/1969', pos: 'Cheif engineer', email: 'William.Parson@mail.com', phone: '1-874-452-4877', company: 'ANG Learning', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bfd"), photo: '<img src="imgs/contacts/small/charlotte-wolks.jpg" border="0" class="contact_photo">', name: 'Charlotte Wolks', dob: '12/4/1989', pos: 'Marketing specialist', email: 'Charlotte.Wolks@mail.com', phone: '1-863-452-4750', company: 'HDF Insurance', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bfe"), photo: '<img src="imgs/contacts/small/pamela-worner.jpg" border="0" class="contact_photo">', name: 'Pamela Worner', dob: '11/17/1976', pos: 'Business analyst', email: 'Pamela.Worner@mail.com', phone: '1-863-548-4879', company: 'ANG Learning', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13bff"), photo: '<img src="imgs/contacts/small/ralf-ross.jpg" border="0" class="contact_photo">', name: 'Ralf Ross', dob: '9/29/1973', pos: 'Sales manager', email: 'Ralf.Ross@mail.com', phone: '1-863-452-9751', company: 'Bank of LA', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13c00"), photo: '<img src="imgs/contacts/small/dan-witley.jpg" border="0" class="contact_photo">', name: 'Dan Witley', dob: '1/5/1978', pos: 'Web developer', email: 'Dan.Witley@mail.com', phone: '1-874-452-9834', company: 'Bank of LA', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
Mongoose: contacts.insertOne({ _id: ObjectId("5b150f6369957c198ff13c01"), photo: '<img src="imgs/contacts/small/anna-harrison.jpg" border="0" class="contact_photo">', name: 'Anna Harrison', dob: '5/8/1984', pos: 'QA engineer', email: 'Anna.Harrison@mail.com', phone: '1-863-548-9751', company: 'Meriton Group', created: new Date("Mon, 04 Jun 2018 10:07:31 GMT"), __v: 0 })
^C
pi@raspberry:~/dhtmlx-json-node/api/contacts $ ^C
pi@raspberry:~/dhtmlx-json-node/api/contacts $ mongo
MongoDB shell version: 3.2.11
connecting to: test
Server has startup warnings: 
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] 
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] ** WARNING: This 32-bit MongoDB binary is deprecated
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] 
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] 
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] ** NOTE: This is a 32 bit MongoDB binary.
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] **       32 bit builds are limited to less than 2GB of data (or less with --journal).
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] **       See http://dochub.mongodb.org/core/32bit
2018-05-30T14:25:48.215+0200 I CONTROL  [initandlisten] 
> use cms
switched to db cms
> show collections
contacts
system.indexes
> db.contacts.find()
{ "_id" : ObjectId("5b150f6369957c198ff13bf6"), "photo" : "<img src=\"imgs/contacts/small/cortny-barrens.jpg\" border=\"0\" class=\"contact_photo\">", "name" : "Cortny Barrens", "dob" : "6/20/1979", "pos" : "Sales manager", "email" : "Cortny.Barrens@mail.com", "phone" : "1-842-458-1452", "company" : "F&M Ltd", "created" : ISODate("2018-06-04T10:07:31.128Z"), "__v" : 0 }
 ... 
Type "it" for more
> exit
bye

Now that the data is in, let's try and get it via the script.

For this we need a controller:

api/contacts/contacts-controller.js

let contactsController = (Model) => {
  // hardcoded header
  let _head = [
    { "id": "photo", "width": "65", "type": "ro", "align": "center", "sort": "na", "value": "<span style='padding-left:60px;'>Name</span>" },
    { "id": "name", "width": "150", "type": "ro", "align": "left", "sort": "na", "value": "#cspan" },
    { "id": "dob", "width": "130", "type": "ro", "align": "left", "sort": "na", "value": "Date of Birth" },
    { "id": "pos", "width": "130", "type": "ro", "align": "left", "sort": "na", "value": "Position" },
    { "id": "email", "width": "170", "type": "ro", "align": "left", "sort": "na", "value": "E-mail Address" },
    { "id": "phone", "width": "150", "type": "ro", "align": "left", "sort": "na", "value": "Phone" },
    { "id": "company", "width": "150", "type": "ro", "align": "left", "sort": "na", "value": "Company" },
    { "id": "info", "width": "*", "type": "ro", "align": "left", "sort": "na", "value": "Additional" }];

  // just find all data in table
  let _readAll = (callback) => {
    Model.find({}, (err, contacts) => {
      if (err) callback(err, null);
      else callback(null, { head: _head, rows: _toRows(contacts) });
    });
  };

  // create {id:x,data:[y,z,...]} from {_id:x,y:'',z:''}
  let _toRows = (rows) => {
    let result = [];
    for (row of rows) {
      result.push({
        id: row._id, data: [row.photo, row.name, row.dob, row.pos, row.email, row.phone, row.company, row.info]
      });
    }
    return result;
  };

  // revealing model pattern, not revealing _toRows()
  return {
    readAll: _readAll
  };
}

module.exports = contactsController;

And we need to call the controller when the route is used:

api/contacts/contacts-router.js

const express = require('express');
const Contacts = require('./contacts-model');
const contactsController = require('./contacts-controller')(Contacts);

let routes = () => {
  let contactsRouter = express.Router();

  contactsRouter.get('/', (req, res) => {
    contactsController.readAll((err, contacts) => {
      if (err) {
        res.sendStatus(400).send(err);
      } else {
        res.json(contacts);
      }
    });
  });

  return contactsRouter;
}

module.exports = routes;

Now the data is retrieved from the database.

Let's review what is done now with the following viewpoints;

The router only is concerned with HTTP traffic. No DB stuff in here, only proper error handling. Yes, there is a dependency on the Contacts model however this is where the following comes in.

Because of the controller uses Dependency Injection the controller can be hypothetically be used for other controller, if the fields are passed with it. This is maybe something we do in future. So here's a list of task that can be done to improve the code even more:

  • pass the structure of the model along with the constructor
  • improve the data population script so it ends after running it, but only after all db actions are completed.
  • improve the error handler so that dhx also knows what is going on if an error occurs
  • make the Date of Birth a real date

Step 4e: PUT for updates

@see branch Step4e

back to top

Now that the GET is working, let's implement the PUT (update).

Plan of approach

  • Implement PUT on the API
  • Make the grid editable
  • Implement fetch
  • Implement onBlur of form

PUT on API

As we want to know what is going in in our HTTP traffic, we use express-log.

npm i --save-dev express-log

Because we need to parse the body that is being send, we need body-parser

npm i --save body-parser

Implement both:

index.js

// add the following lines to the top
const bodyParser = require('body-parser');
const logger = require('express-log');

//...

// set up middleware
app.use(logger());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

Now we can add the update step in the controller:

api/contacts/contacts-controller.js

// find one and update with one atomic operation, forcing the altered document to return
let _updateOne = (id, data, callback) => {
  Model.findOneAndUpdate({ _id: id }, data, { new: true }, (err, contact) => {
    if (err) callback(err, null);
    else callback(null, contact);
  });
};

And connect the router to the controller:

api/contacts/contacts-router.js

contactsRouter.put('/:id', (req, res) => {
  contactsController.updateOne(req.params.id, req.body, (err, contact) => {
    if (err) {
      res.sendStatus(400).end(err);
    } else {
      res.json(contact);
    }
  });
});

Let's use postman to send a PUT request. Keep in mind the following;

  • Your id is different, look in the GET call to get your own id
  • It is important to set Content-Type to application/json in the header
{
	"photo": "<img src='imgs/contacts/small/cortny-barrens.jpg' border='0' class='contact_photo'>",
	"name": "Cortny Barrens 2",
	"dob": "6/20/1979",
	"pos": "Sales manager",
	"email": "Cortny.Barrens@mail.com",
	"phone":"1-842-458-1452",
	"company":"F&M Ltd"
}

Postman showing a PUT request and a returned object

The only thing left is to connect the front-end. Instead of using dhtmlxDataProcessor on browser-side, connected to the dhtmlxConnector server-side, I'm going to wire up the grid directly to fetch.

dhtmlxDataProcessor and dhtmlxConnector interaction diagram.

To create this we need basic understanding of the events of the grid.

I've added all available event-handlers that are mentioned in the documentation. Because of the various number of arguments, some need to return something and the lack of mention of the event-name, I needed to add all the events separately.

Create a new file

public/dhx-fetch-dp-js

function attachDp(obj) {
  //fires after a row has been deleted from the grid
  obj.attachEvent('onAfterRowDeleted', (id, pid) => {
    console.log('onAfterRowDeleted', id, pid);
  });
  //fires when the user starts selecting a block
  obj.attachEvent('onBeforeBlockSelected', (rId, cInd) => {
    console.log('onBeforeBlockSelected', rId, cInd);
  });
  //fires when the drag operation starts
  obj.attachEvent('onBeforeDrag', (id) => {
    console.log('onBeforeDrag', id);
  });
  //fires right before a row is deleted
  obj.attachEvent('onBeforeRowDeleted', (rId) => {
    console.log('onBeforeRowDeleted', rId);
  });
  //fires after clicking by the right mouse button on the selection block
  obj.attachEvent('onBlockRightClick', (block, object) => {
    console.log('onBlockRightClick', block, object);
  });
  //fires when a calendar pops up in the grid
  obj.attachEvent('onCalendarShow', (myCal, rowId, colInd) => {
    console.log('onCalendarShow', myCal, rowId, colInd);
  });
  //fires immediately after a cell has been selected
  obj.attachEvent('onCellMarked', (rId, ind) => {
    console.log('onCellMarked', rId, ind);
  });
  //fires immediately after a cell is unselected
  obj.attachEvent('onCellUnMarked', (rId, ind) => {
    console.log('onCellUnMarked', rId, ind);
  });
  //fires after the state of a checkbox has been changed
  obj.attachEvent('onCheck', (rId, cInd, state) => {
    console.log('onCheck', rId, cInd, state);
  });
  //the event is deprecated, use the onCheck event instead; fires after the state was changed
  // obj.attachEvent('onCheckbox', (rId, cInd, state) => {
  //   console.log('onCheckbox', rId, cInd, state);
  // });
  //fires when the grid is cleared (reloaded)
  obj.attachEvent('onClearAll', () => {
    console.log('onClearAll');
  });
  //fires after the values have been collected to fill the select filter
  obj.attachEvent('onCollectValues', (index) => {
    console.log('onCollectValues', index);
    return true; //mandatory for the default processing
  });
  //fires when the data is loaded to the grid but hasn't been rendered yet
  obj.attachEvent('onDataReady', () => {
    console.log('onDataReady');
  });
  //fires on calendar's initialization on the page
  obj.attachEvent('onDhxCalendarCreated', (myCal) => {
    console.log('onDhxCalendarCreated', myCal);
  });
  //fires on the end of distributed parsing
  obj.attachEvent('onDistributedEnd', () => {
    console.log('onDistributedEnd');
  });
  //fires when an item is dragged to another target and the mouse is released, the event can be blocked
  obj.attachEvent('onDrag', (sId, tId, sObj, tObj, sInd, tInd) => {
    console.log('onDrag', sId, tId, sObj, tObj, sInd, tInd);
  });
  //fires before requesting additional data from the server in case of dynamic Smart Rendering or dynamic Paging
  obj.attachEvent('onDynXLS', (start, count) => {
    console.log('onDynXLS', start, count);
  });
  //fires when the edit operation was canceled
  obj.attachEvent('onEditCancel', (rowId, colInd, value) => {
    console.log('onEditCancel', rowId, colInd, value);
  });
  //fires 1-3 times depending on cell's editability (see the stage parameter)
  obj.attachEvent('onEditCell', (stage, rId, cInd, nValue, oValue) => {
    console.log('onEditCell', stage, rId, cInd, nValue, oValue);
  });
  //fires on clicking the dhtmlxgrid area which is not filled with data
  obj.attachEvent('onEmptyClick', (ev) => {
    console.log('onEmptyClick', ev);
  });
  //fires immediately after the Enter key was pressed
  obj.attachEvent('onEnter', (id, ind) => {
    console.log('onEnter', id, ind);
  });
  //fires when filtering is completed (filtering extension)
  obj.attachEvent('onFilterEnd', (elements) => {
    console.log('onFilterEnd', elements);
  });
  //fires when filtering has been activated but before the real filtering started
  obj.attachEvent('onFilterStart', (indexes, values) => {
    console.log('onFilterStart', indexes, values);
    return true; //The event is blockable. Returning false will block the default action. returning true will confirm filtering
  });
  //fires immediately after a row has been added/deleted or the grid has been reordered
  obj.attachEvent('onGridReconstructed', (obj) => {
    console.log('onGridReconstructed', obj);
  });
  //fires when a grid was grouped by some column
  obj.attachEvent('onGroup', () => {
    console.log('onGroup');
  });
  //fires when a group was opened/closed
  obj.attachEvent('onGroupStateChanged', (value, state) => {
    console.log('onGroupStateChanged', value, state);
  });
  //fires on pressing the Down-Arrow button while the last row of the page is selected
  obj.attachEvent('onLastRow', () => {
    console.log('onLastRow');
  });
  //fires when validation runs successfully
  obj.attachEvent('onLiveValidationCorrect', (id, index, value, input, rule) => {
    console.log('onLiveValidationCorrect', id, index, value, input, rule);
  });
  //fires when validation runs and rules execution are failed
  obj.attachEvent('onLiveValidationError', (id, index, value, input, rule) => {
    console.log('onLiveValidationError', id, index, value, input, rule);
  });
  //fires when the mouse pointer is moved over a cell
  obj.attachEvent('onMouseOver', (id, ind) => {
    console.log('onMouseOver', id, ind);
  });
  //fires on each resize iteration
  obj.attachEvent('onResize', (cInd, cWidth, obj) => {
    console.log('onResize', cInd, cWidth, obj);
  });
  //fires when resizing of a column is finished
  obj.attachEvent('onResizeEnd', (obj) => {
    console.log('onResizeEnd', obj);
  });
  //fires immediately after the right mouse button has been clicked on a grid's row
  obj.attachEvent('onRightClick', (id, ind, obj) => {
    console.log('onRightClick', id, ind, obj);
  });
  //fires right after a row has been added to the grid
  obj.attachEvent('onRowAdded', (rId) => {
    console.log('onRowAdded', rId);
  });
  //fires when the row is hiding
  obj.attachEvent('onRowHide', (id, state) => {
    console.log('onRowHide', id, state);
  });
  //fires after the ID of a row has been changed (changeRowId, setRowId, dataprocessor)
  obj.attachEvent('onRowIdChange', (oldId, newId) => {
    console.log('onRowIdChange', oldId, newId);
  });
  //fires when the row is added to the grid and filled with data
  obj.attachEvent('onRowInserted', (row, rInd) => {
    console.log('onRowInserted', row, rInd);
  });
  //fires for each row pasted from the clipboard (block selection extension)
  obj.attachEvent('onRowPaste', (rId) => {
    console.log('onRowPaste', rId);
  });
  //fires immediately after a row in the grid has been clicked
  obj.attachEvent('onRowSelect', (id, ind) => {
    console.log('onRowSelect', id, ind);
  });
  //fires immediately after scrolling has occured
  obj.attachEvent('onScroll', (sLeft, sTop) => {
    console.log('onScroll', sLeft, sTop);
  });
  //fires immediately when the selection state has been changed
  obj.attachEvent('onSelectStateChanged', (id) => {
    console.log('onSelectStateChanged', id);
  });
  //fires after the stat values have been calculated
  obj.attachEvent('onStatReady', () => {
    console.log('onStatReady');
  });
  //fires when sub-row-ajax cell loads its data
  obj.attachEvent('onSubAjaxLoad', (id, content) => {
    console.log('onSubAjaxLoad', id, content);
  });
  //fires when the creation of a sub-grid was initialized (can be blocked)
  obj.attachEvent('onSubGridCreated', (obj, rowId, rowIndex) => {
    console.log('onSubGridCreated', obj, rowId, rowIndex);
    return true;
  });
  //fires when a sub-row(sub-grid) was opened/closed
  obj.attachEvent('onSubRowOpen', (id, state) => {
    console.log('onSubRowOpen', id, state);
  });
  //fires when data synchronization is finished
  obj.attachEvent('onSyncApply', () => {
    console.log('onSyncApply');
  });
  //fires during the tabulation in the grid, blockable
  obj.attachEvent('onTab', (mode) => {
    console.log('onTab', mode);
  });
  //fires when the grid was ungrouped
  obj.attachEvent('onUnGroup', () => {
    console.log('onUnGroup');
  });
  //fires when validation runs successfully
  obj.attachEvent('onValidationCorrect', (id,index,value,rule) => {
    console.log('onValidationCorrect', id,index,value,rule);
  });
  //fires when validation runs and rules execution are failed
  obj.attachEvent('onValidationError', (id,index,value,rule) => {
    console.log('onValidationError', id,index,value,rule);
  });
}

Then include the file for loading

public/index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <link rel="stylesheet" type="text/css" href="//cdn.dhtmlx.com/edge/skins/web/dhtmlx.css">
  <script src="//cdn.dhtmlx.com/edge/dhtmlx.js"></script>
  <link rel="stylesheet" type="text/css" href="style.css">
</head>

<body>
  <script type="text/javascript" src="dhx-fetch-dp.js"></script><!--include before-->
  <script type="text/javascript" src="app.js"></script>
</body>

</html>

And call the event bindings

public/app.js

// ...
contactsGrid = contactsLayout.cells("a").attachGrid();
contactsGrid.load("api/contacts?type=" + A.deviceType, function () {
  contactsGrid.selectRow(0, true);
}, "json");

attachDp(contactsGrid);
// attach form
contactsGrid.attachEvent("onRowSelect", contactsFillForm);
contactsGrid.attachEvent("onRowInserted", contactsGridBold);
// ...

Screenshot of the events in console

Now you can click on rows, press tab/enter/shift-tab/arrow-keys, move headings, rightclick and you see the events fire in the console window.

Looking at the events, the onEditCell looks like the most suitable for the PUT request.

//fires 1-3 times depending on cell's editability (see the stage parameter)
obj.attachEvent('onEditCell', (stage, rowId, colIndex, newValue, oldValue) => {
  //stage the stage of editing (0-before start; can be canceled if return false,1 - the editor is opened,2- the editor is closed)
  const beforeStart = 0;
  const editorOpened = 1;
  const editorClosed = 2;

  if (stage === editorClosed & newValue !== oldValue) {
    let fieldName = obj.getColumnId(colIndex);
    let request = `{"${fieldName}":"${newValue}"}`;
    console.log('request',  JSON.parse(request));
    fetch(`/api/contacts/${rowId}`, {
      headers: {
        'Content-Type': 'application/json'
      },
      method: 'PUT',
      body: request
    })
      .then(response => response.json())
      .then(response => {
        console.log('response', response);
      })
      .catch(err => { console.error(err) });

    return true;
  }
});

Also you need to change api/contacts/contacts-controller.js to allow editing ro to ed

  let _head = [
    { "id": "photo", "width": "65", "type": "ro", "align": "center", "sort": "na", "value": "<span style='padding-left:60px;'>Name</span>" },
    { "id": "name", "width": "150", "type": "ed", "align": "left", "sort": "na", "value": "#cspan" },
    { "id": "dob", "width": "130", "type": "ed", "align": "left", "sort": "na", "value": "Date of Birth" },
    { "id": "pos", "width": "130", "type": "ed", "align": "left", "sort": "na", "value": "Position" },
    { "id": "email", "width": "170", "type": "ed", "align": "left", "sort": "na", "value": "E-mail Address" },
    { "id": "phone", "width": "150", "type": "ed", "align": "left", "sort": "na", "value": "Phone" },
    { "id": "company", "width": "150", "type": "ed", "align": "left", "sort": "na", "value": "Company" },
    { "id": "info", "width": "*", "type": "ed", "align": "left", "sort": "na", "value": "Additional" }
  ];

And you need to set the editEvents in public/app.js

// attach grid
contactsGrid = contactsLayout.cells("a").attachGrid();
contactsGrid.enableEditEvents(true,true,true);
contactsGrid.init();

Note to above:

  • There is no proper error handling (yet), it just assumes it works always.
  • The dataprocessor is now linked to a specific API-url, no no reuse for other grids or object (yet)
  • The dataprocessor assumes it is linked to a grid

Let's have a look at the result;

Application showing the request and the response in the console when updating any field in the contactsGrid

Form

Next up is the form. Let's do the same and connect all the events in a different js script

  • rename dhx-fetch-dp.js to dhx-fetch-dp-grid.js
  • create a new file dhx-fetch-dp-form.js
  • include both files in index.html
  • add another argument (objectName) of the function and rename the function in dhx-fetch-dp-grid.js, also prefix the console.log() with objectName using search/replace, or get it from the branch

public/dhx-fetch-dp-grid.js

function attachDpGrid(obj, objectName) {
  //fires after a row has been deleted from the grid
  obj.attachEvent('onAfterRowDeleted', (id, pid) => {
    console.log(objectName, 'onAfterRowDeleted', id, pid);
  });
// ...

public/index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <link rel="stylesheet" type="text/css" href="//cdn.dhtmlx.com/edge/skins/web/dhtmlx.css">
  <script src="//cdn.dhtmlx.com/edge/dhtmlx.js"></script>
  <link rel="stylesheet" type="text/css" href="style.css">
</head>

<body>
  <script type="text/javascript" src="dhx-fetch-dp-grid.js"></script>
  <script type="text/javascript" src="dhx-fetch-dp-form.js"></script>
  <script type="text/javascript" src="app.js"></script>
</body>

</html>

public/dhx-fetch-dp-form.js

function attachDpForm(obj, objectName) {
  // onAfterReset	fires after resetting the form
  obj.attachEvent('onAfterReset', (formId) => {
    console.log(objectName, 'onAfterReset', formId);
  });
  // onAfterSave	fires after data has been saved in DB
  obj.attachEvent('onAfterSave', (formId, response) => {
    console.log(objectName, 'onAfterSave', formId, response);
  });
  // onAfterValidate	fires after the validation
  obj.attachEvent('onAfterValidate', (success) => {
    console.log(objectName, 'onAfterValidate', success);
  });
  // onBeforeChange	fires before the data in some input changed ( by user actions )
  obj.attachEvent('onBeforeChange', (itemName, oldValue, newValue) => {
    console.log(objectName, 'onBeforeChange', itemName, oldValue, newValue);
    return true;
  });
  // onBeforeClear	fires before the user clears the list of files to upload (clicks the button )
  obj.attachEvent('onBeforeClear', () => {
    console.log(objectName, 'onBeforeClear');
    return true;
  });
  // onBeforeDataLoad	fires after the data for the form received, but before it's assigned to actual fields
  obj.attachEvent('onBeforeDataLoad', (formId, values) => {
    console.log(objectName, 'onBeforeDataLoad', formId, values);
    return true;
  });
  // onBeforeFileAdd	fires when the user adds a file to the upload queue
  obj.attachEvent('onBeforeFileAdd', (fileName) => {
    console.log(objectName, 'onBeforeFileAdd', fileName);
    return true;
  });
  // onBeforeFileRemove	fires before the user removes a single file from the list of files to upload (clicks the button )
  obj.attachEvent('onBeforeFileRemove', (clientFileName, serverFileName) => {
    console.log(objectName, 'onBeforeFileRemove', clientFileName, serverFileName);
    return true;
  });
  // onBeforeFileUpload	fires before file uploading has started
  obj.attachEvent('onBeforeFileUpload', (mode, loader, formData) => {
    console.log(objectName, 'onBeforeFileUpload', mode, loader, formData);
    return true;
  });
  // onBeforeReset	fires before resetting the form
  obj.attachEvent('onBeforeReset', (formId, values) => {
    console.log(objectName, 'onBeforeReset', formId, values);
    return true;
  });
  // onBeforeSave	fires before saving the form
  obj.attachEvent('onBeforeSave', (formId, values) => {
    console.log(objectName, 'onBeforeSave', formId, values);
    return true;
  });
  // onBeforeValidate	fires when validation has started but is not applied yet
  obj.attachEvent('onBeforeValidate', (formId) => {
    console.log(objectName, 'onBeforeValidate', formId);
    return true;
  });
  // onBlur	fires when the user moves the mouse pointer out of the input
  obj.attachEvent('onBlur', (itemName, value) => {
    // value: item value (for checkboxes and radios only)
    console.log(objectName, 'onBlur', itemName, value);
  });
  // onButtonClick	fires when the user clicks a button
  obj.attachEvent('onButtonClick', (itemName) => {
    console.log(objectName, 'onButtonClick', itemName);
  });
  // onChange	fires when data in some input was changed
  obj.attachEvent('onChange', (itemName, value, state) => {
    //state = checked/unchecked (for checkboxes and radios only)
    console.log(objectName, 'onChange', itemName, value, state);
  });
  // onClear	when the user clears the list of files to upload (clicks on button )
  obj.attachEvent('onClear', () => {
    console.log(objectName, 'onClear');
  });
  // onDisable	fires when the container is disabled after being enabled
  obj.attachEvent('onDisable', (itemName) => {
    console.log(objectName, 'onDisable', itemName);
  });
  // onEditorAccess	fires when the user accesses the editor body
  obj.attachEvent('onEditorAccess', (name, type, event, editor, form) => {
    // name	string	the item's name
    // type	string	the type of the accessing action (e.g. "mousedown","focus","mouseup","click", "blur")
    // ev	object	the event object
    // editor	object	the editor instance
    // form	object	the form instance
    console.log(objectName, 'onEditorAccess', name, type, event, editor, form);
  });
  // onEditorToolbarClick	fires when the user clicks the editor toolbar
  obj.attachEvent('onEditorToolbarClick', (name, toolbarId, editor, form) => {
    console.log(objectName, 'onEditorToolbarClick', name, toolbarId, editor, form);
  });
  // onEnable	fires when the container control is enabled after being disabled
  obj.attachEvent('onEnable', (itemName) => {
    console.log(objectName, 'onEnable', itemName);
  });
  // onEnter	fires on pressing the Enter button when the mouse pointer is set in an input control
  obj.attachEvent('onEnter', (itemName) => {
    console.log(objectName, 'onEnter', itemName);
  });
  // onFileAdd	fires when the user adds a file to the upload queue
  obj.attachEvent('onFileAdd', (fileName) => {
    console.log(objectName, 'onFileAdd', fileName);
  });
  // onFileRemove	fires when the user removes single file from the list of files to upload (clicks the button )
  obj.attachEvent('onFileRemove', (clientFileName, serverFileName) => {
    console.log(objectName, 'onFileRemove', clientFileName, serverFileName);
  });
  // onFocus	fires when an input gets focus
  obj.attachEvent('onFocus', (itemName, value) => {
    // value: item value (for checkboxes and radios only)
    console.log(objectName, 'onFocus', itemName, value);
  });
  // onImageUploadFail	fires when an image was uploaded incorrectly
  obj.attachEvent('onImageUploadFail', (itemName, extra) => {
    console.log(objectName, 'onImageUploadFail', itemName, extra);
  });
  // onImageUploadSuccess	fires when an image was uploaded correctly
  obj.attachEvent('onImageUploadSuccess', (itemName, imageName, extra) => {
    console.log(objectName, 'onImageUploadSuccess', itemName, imageName, extra);
  });
  // onInfo	fires when the user clicks the Info icon
  obj.attachEvent('onInfo', (itemName, event) => {
    console.log(objectName, 'onInfo', itemName, event);
  });
  // onInputChange	fires when data in an input was changed and the cursor is still in this input
  obj.attachEvent('onInputChange', (itemName, value, form) => {
    console.log(objectName, 'onInputChange', itemName, value, form);
  });
  // onKeydown	fires when the native "onkeydown" event is triggered
  obj.attachEvent('onKeydown', (inputElement, eventObject, itemName, value) => {
    console.log(objectName, 'onKeydown', inputElement, eventObject, itemName, value);
  });
  // onKeyup	fires when the native "onkeyup" event is triggered
  obj.attachEvent('onKeyup', (inputElement, eventObject, itemName, value) => {
    console.log(objectName, 'onKeyup', inputElement, eventObject, itemName, value);
  });
  // onOptionsLoaded	fires after the item options were completely loaded from the server to the client
  obj.attachEvent('onOptionsLoaded', (itemName) => {
    console.log(objectName, 'onOptionsLoaded', itemName);
  });
  // onUploadCancel	fires when the user cancels uploading of a file (clicks the button ).
  obj.attachEvent('onUploadCancel', (fileName) => {
    console.log(objectName, 'onUploadCancel', fileName);
  });
  // onUploadComplete	fires when all files from the list have been uploaded to the server
  obj.attachEvent('onUploadComplete', (count) => {
    console.log(objectName, 'onUploadComplete', count);
  });
  // onUploadFail	fires when the file upload has failed
  obj.attachEvent('onUploadFail', (fileName) => {
    //fileName the real name of the file (as it's displayed in the control)
    console.log(objectName, 'onUploadFail', fileName);
  });
  // onUploadFile	fires when a single file from the list has been uploaded to the server
  obj.attachEvent('onUploadFile', (clientFileName, serverFileName) => {
    console.log(objectName, 'onUploadFile', clientFileName, serverFileName);
  });
  // onValidateError	fires for each error during validation
  obj.attachEvent('onValidateError', (name, value, result) => {
    console.log(objectName, 'onValidateError', name, value, result);
  });
  // onValidateSuccess	fires for each success during validation
  obj.attachEvent('onValidateSuccess', (name, value, result) => {
    console.log(objectName, 'onValidateSuccess', name, value, result);
  });
  // onXLE	fires when the data loading is finished and a component or data is rendered
  obj.attachEvent('onXLE', () => {
    //callback order: onXLS event => [request] => onXLE event => doOnLoad()
    console.log(objectName, 'onXLE');
  });
  // onXLS	fires when XML loading started
  obj.attachEvent('onXLS', () => {
    //callback order: onXLS event => [request] => onXLE event => doOnLoad()
    console.log(objectName, 'onXLS');
  });
}

update our main app so it binds the events

public/app.js

//...
function contactsInit(cell) {
  if (contactsLayout == null) {
    // init layout
    contactsLayout = cell.attachLayout("2U");
    contactsLayout.cells("a").hideHeader();
    contactsLayout.cells("b").hideHeader();
    contactsLayout.cells("b").setWidth(330);
    contactsLayout.cells("b").fixSize(true, true);
    contactsLayout.setAutoSize("a", "a;b");

    // attach grid
    contactsGrid = contactsLayout.cells("a").attachGrid();
    contactsGrid.enableEditEvents(true,true,true);
    contactsGrid.init();
    contactsGrid.load("api/contacts?type=" + A.deviceType, function () {
      contactsGrid.selectRow(0, true);
    }, "json");

    attachDpGrid(contactsGrid, 'contactsGrid'); // <<<<

    // attach form
    contactsGrid.attachEvent("onRowSelect", contactsFillForm);
    contactsGrid.attachEvent("onRowInserted", contactsGridBold);

    contactsForm = contactsLayout.cells("b").attachForm([
      { type: "settings", position: "label-left", labelWidth: 110, inputWidth: 160 },
      { type: "container", name: "photo", label: "", inputWidth: 160, inputHeight: 160, offsetTop: 20, offsetLeft: 65 },
      { type: "input", name: "name", label: "Name", offsetTop: 20 },
      { type: "input", name: "email", label: "E-mail" },
      { type: "input", name: "phone", label: "Phone" },
      { type: "input", name: "company", label: "Company" },
      { type: "input", name: "info", label: "Additional info" }
    ]);
    attachDpForm(contactsForm, 'contactsForm'); // <<<<
    contactsForm.setSizes = contactsForm.centerForm;
    contactsForm.setSizes();
  }
}
//...

Form events showing in the console

Looking at the events, the onChange seems the most suitable to fire a request to the API.

This is what we have to do;

  • get all data to be send
  • send the data
  • sync with the grid

Getting the rowId is not that hard:

obj.getItemValue('id');

Sending the request is the same as in the grid, but then we have a problem; how are we going to communicate back to grid within this function? Let me try to see if we can create a custom event:

obj.callEvent("onAfterChange", [rowId, itemName, value]);

So in the script:

public/dhx-fetch-dp-form.js

// onChange	fires when data in some input was changed
obj.attachEvent('onChange', (itemName, value, state) => {
  //state = checked/unchecked (for checkboxes and radios only)
  console.log(objectName, 'onChange', itemName, value, state);
  let rowId = obj.getItemValue('id');
  let request = `{"${itemName}":"${value}"}`;
  console.log(objectName, 'request', JSON.parse(request), rowId);
  fetch(`/api/contacts/${rowId}`, {
    headers: {
      'Content-Type': 'application/json'
    },
    method: 'PUT',
    body: request
  })
    .then(response => response.json())
    .then(response => {
      console.log(objectName, 'response', response);
      // call a custom event, onAfterChange doesn't exist
      obj.callEvent("onAfterChange", [rowId, itemName, value]);
    })
    .catch(err => { console.error(err) });
});

If that is possible we can easily 'catch' it again in the app.js, where all items are spaghetti-coded anyway:

pubilc/app.js

contactsForm.attachEvent("onAfterChange", (rowId, field, value) => {
  fieldIndex = contactsGrid.getColIndexById(field);
  console.log('contactsForm', 'onAfterChange', 'CUSTOM EVENT!', rowId, field, value, fieldIndex);
  contactsGrid.cells(rowId, fieldIndex).setValue(value);
});

What do you know, it works. You can check it out at Brach Step4e

Screenshot of updating the form: connected to the grid

Step 4d: POST for create

back to top

We take the same approach as the previous step (which is, design top-down, build bottom-up):

  • create the API and test it
  • connect the toolbar button
  • complete interaction with the grid

You can see/clone the result of this chapter on Branch Step4e (sorry, messed up right order, but the hyperlink is right)

api/contacts/contacts-controller.js

//add following function
let _createOne = (data, callback) => {
  Model.create(data, (err, contact) => {
    if (err) callback(err, null);
    else callback(null, contact);
  });
};
//...
// revealing model pattern, not revealing _toRows()
return {
  readAll: _readAll,
  updateOne: _updateOne,
  createOne: _createOne // <<<< add this line
};

api/contacts/contacts-router.js

//add following function
contactsRouter.post('/', (req, res) => {
  contactsController.createOne(req.body, (err, contact) => {
    if(err) {
      res.sendStatus(400).end(err);
    } else {
      res.json(contact);
    }
  });
});

Test using postman, payload:

{
	"photo": "<img src='imgs/contacts/small/remi-kristelijn.jpg' border='0' class='contact_photo' height='40' width='40'>",
	"name": "Remi Kristelijn",
	"dob": "12/17/1079",
	"pos": "Enterprise Architect",
	"email": "rkristelijn@mail.com",
	"phone":"1-842-458-1452",
	"company":"Accenture"
}

Postman showing POST method

listening on *:3000
Connected to mongoose
Mongoose: contacts.insertOne({ _id: ObjectId("5b1a36c201c7e91842e32afc"), photo: '<img src=\'imgs/contacts/small/remi-kristelijn.jpg\' border=\'0\' class=\'contact_photo\'>', name: 'Remi Kristelijn', dob: '12/17/1079', pos: 'Enterprise Architect', email: 'rkristelijn@mail.com', phone: '1-842-458-1452', company: 'Accenture', created: new Date("Fri, 08 Jun 2018 07:56:50 GMT"), __v: 0 })
POST / 200 43ms

Now let's connect that to the button;

First I tried to add an event within the initialization of the contacts layout in functioncontactsInit(). I also added one in the projectsInit() like the one below.

public/app.js

contactsInit(cell) { 
  // ...
  mainToolbar.attachEvent("onClick", (buttonId) => {
    console.log("mainToolbar/contact", "onClick", buttonId);
  });
  // ...
}

projectsInit(cell) {
  // ...
  mainToolbar.attachEvent("onClick", (buttonId) => {
    console.log("mainToolbar/contact", "onClick", buttonId);
  });
  // ...
}

However when switching to projects and back, clicking the button resulted in both events fireing. So I needed to filter out to fire only when contacts was selected.

public/app.js

contactsInit(cell) { 
  // ...
  mainToolbar.attachEvent("onClick", (buttonId) => {
    if(mainSidebar.getActiveItem() === 'contacts') {
        console.log("mainToolbar/contact", "onClick", buttonId);
        let rowId = contactsGrid.uid();
        contactsGrid.addRow(rowId, "");
        contactsGrid.selectRowById(rowId);
      }
  });
  // ...
}

projectsInit(cell) {
  // ...
  mainToolbar.attachEvent("onClick", (buttonId) => {
    if(mainSidebar.getActiveItem() === 'projects') {
      console.log("mainToolbar/contact", "onClick", buttonId);
    }
  });
  // ...
}

Now let's connect the API call.

/public/dhx-fetch-dp-grid.js

//fires right after a row has been added to the grid
  obj.attachEvent('onRowAdded', (rId) => {
    console.log(objectName, 'onRowAdded', rId);
    fetch(`/api/contacts/`, {
      headers: {
        'Content-Type': 'application/json'
      },
      method: 'POST'
    })
      .then(response => response.json())
      .then(response => {
        console.log(objectName, 'response', response);

        /* NOTE: we need to replace the dhx-created (temporarely) rowId with the server row-id */
        obj.callEvent("onAfterRowAdded", [rId, response._id]);
      })
      .catch(err => { console.error(err) });
  });

As you can see we need another event to do something after the creation in the database. So we fire a custom event: onAfterRowAdded and catch that again in app.js

public/app.js

contactsInit(cell) { 
  // ...
  contactsGrid.attachEvent("onAfterRowAdded", (tempRowId, serverRowId) => {
      console.log('contactsGrid', 'onAfterRowAdded', 'CUSTOM EVENT!', tempRowId, serverRowId);
      contactsGrid.changeRowId(tempRowId, serverRowId);
    });
  // ...
}

Now there one thing that is impossible to populate now, the avatar image. Let's predefault that in the model.

/api/contacts/contacts-model.js

const ContactsSchema = mongoose.Schema({
  // ...
  photo: {
    type: String,
    default: "<img src='imgs/contacts/small/some-one.png' border='0' class='contact_photo' height='40' width='40'>"
  },
  // ...
});

Now we can add new contacts.

Screenshot of the app, being able to add and update contacts

Step 4f: DELETE for delete

back to top

api/contacts/contacts-controller.js

let _deleteOne = (id, callback) => {
  Model.findByIdAndRemove({ _id: id }, (err) => {
    if (err) callback(err, null);
    else callback(null, null);
  });
};
//...
// revealing model pattern, not revealing _toRows()
return {
  readAll: _readAll,
  updateOne: _updateOne,
  createOne: _createOne,
  deleteOne: _deleteOne // <<<<
};
// ...

api/contacts/contacts-router.js

contactsRouter.delete('/:id', (req, res) => {
  contactsController.deleteOne(req.params.id, (err) => {
    if (err) {
      res.sendStatus(400).end(err);
    } else {
      res.sendStatus(204).end(req.params.id + " removed");
    }
  });
});

public/app.js

mainToolbar.attachEvent("onClick", (buttonId) => {
  if (mainSidebar.getActiveItem() === 'contacts') {
    console.log("mainToolbar/contact", "onClick", buttonId);
    let rowId;
    switch (buttonId) {
      case "add":
        rowId = contactsGrid.uid();
        contactsGrid.addRow(rowId, "");
        contactsGrid.selectRowById(rowId);
        break;
      case "del":
        rowId = contactsGrid.getSelectedRowId();
        let rowIndex = contactsGrid.getRowIndex(rowId);
        contactsGrid.deleteRow(rowId);
        // highlight the next record, or the previous record when deleting the last line
        if (rowIndex < contactsGrid.getRowsNum()) {
          contactsGrid.selectRow(rowIndex, true);
        } else {
          contactsGrid.selectRow(rowIndex - 1, true)
        }
        break;
    }
  }
});

public/dhx-fetch-dp-grid.js

//fires after a row has been deleted from the grid
obj.attachEvent('onAfterRowDeleted', (id, pid) => {
  console.log(objectName, 'onAfterRowDeleted', id, pid);
  fetch(`/api/contacts/${id}`, {
    headers: {
      'Content-Type': 'application/json'
    },
    method: 'DELETE'
  })
    .then(response => {
      console.log(objectName, 'response', response.statusText);
    })
    .catch(err => { console.error(err) });
});

References

back to top

Markdown

Create a branch

git checkout -b step1

About

This is a short tutorial to set up dhtmlx (dhx) using only JSON and node/mongodb as a back-end and the REST API. Recently I followed the 'Your First App' tutorial and felt unsatisfied with what I've learned. So I decided to push a little harder and use a demo app 'CRM System' as the base and create my own tutorial.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published