Interacting with bookmarks

In this tutorial, you are going to learn with sample code how to interact with bookmarks through APIs using enigma.js, by building a simple bookmark manager.

If you're just interested in the code produced in this tutorial, skip to the Summary section.

Note: You need access to a Qlik Sense SaaS tenant, and either tenant administrator rights yourself, or the means to get a web integration set up for you. You can sign up for a free trial if you don't have an existing tenant.

Prerequisites

General Qlik Sense knowledge about bookmarks and selections plus basic HTML and Javascript experience would be preferable.

What you need:

  • Node.js (version 12 or newer)
  • A terminal (for example Git Bash on Windows, or Terminal.app on Mac)
  • A modern web browser, for example Google Chrome
  • A text editor or IDE of your choice, for example Visual Studio Code
  • An existing web integration, or possibility to get one created in your tenant

Project structure and setup

For this project, you are going to use:

  • enigma.js to communicate with the Qlik Associative Engine
  • parceljs to start a dev server and bundle the application
  • bootstrap as a CSS framework

With your preferred terminal, create the project folder, initialize with npm init, and install the required dependencies:

# new folder to contain the project files:
mkdir bookmark-manager
cd bookmark-manager

# initialize the project without having it ask any questions
# this creates a `package.json` in the root of your directory
npm init -y

# install the required dependencies
npm i -S enigma.js
npm i -D parcel@next

In the scripts property of the newly generated package.json file, add a new script entry allowing you to start a dev-server. Next, add a new property called browserlist for parceljs to correctly set the browser configuration for the app bundle (see parceljs#browserlist for more details)

  "scripts": {
    "start": "parcel index.html --open"
  },
  "browserslist": [
    "last 1 Chrome version"
  ],

  • Create an index.html file using your favorite editor and save the file in the project folder.

<!DOCTYPE html>
<html>
  <head>
    <title>Bookmark Manager</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css"
    />
    <script type="text/javascript" src="app.js"></script>
  </head>
  <body>
    <div class="jumbotron text-center">
      <h1>Bookmark Manager.</h1>
      <p>This a web app example showcasing bookmarks with enigma.js.</p>
    </div>
    <div class="container">
      <table class="table">
        <thead class="thead-dark">
          <tr>
            <th scope="col">#</th>
            <th scope="col">title</th>
            <th scope="col">fields</th>
            <th scope="col">sheetId</th>
            <th scope="col">creationDate</th>
            <th scope="col">actions</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
    </div>
  </body>
</html>

Note: In the html file, you have a <table> element that contains a predefined header (<thead> tag) for labeling the columns for the bookmark list.

Next, create the communication module required for client authentication and for creating an enigma.js session.

  • Create a new JavaScript file and call it comm.js. This file contains the necessary configuration and method helpers for doing requests against your tenant.

Add the configuration object:

const config = {
  tenantUri: 'https://your-tenant.us.qlikcloud.com',
  webIntegrationId: 'yourWebIntegrationId',
  appId: 'yourAppId',
};

Then add request helper method that has mode CORS, credentials set to true, and qlik-web-integration-id configured in the headers.

function request(path) {
  return fetch(config.tenantUri + path, {
    mode: 'cors', // cors must be enabled
    credentials: 'include', // credentials must be included
    headers: {
      // needed in order to whitelist your domain
      'qlik-web-integration-id': config.webIntegrationId,
    },
  });
}

Next, add a sign-in helper method that uses the previously created request method for fetching the /api/v1/users/me endpoint. Note that the full Users.

async function login() {
  // loginUrl will be used to redirect to your tenant log in page,
  // and once signed in, redirect back to your web-app
  const loginUrl = new URL(`${config.tenantUri}/login`);
  loginUrl.searchParams.append('returnto', window.location.href);
  loginUrl.searchParams.append(
    'qlik-web-integration-id',
    config.webIntegrationId
  );
  // fetches the current logged in user's metadata and will return
  // true if current user is logged in, respectively false if not
  const loggedIn = await request('/api/v1/users/me').then(
    (response) => response.status === 200
  );
  if (!loggedIn) {
    window.location.href = loginUrl;
  }
}

Note: The full Users REST API specification can be found

here.

Finally, add the method that creates an enigma session, connects to your tenant's websocket, and opens the app:

  • import enigma.js and its schema at the very beginning of the file
    import enigma from 'enigma.js';
    import schema from 'enigma.js/schemas/12.20.0.json';
  • add the getApp method
    async function getApp() {
      // fetch the CSRF token required for opening the websocket
      const res = await request('/api/v1/csrf-token', false);
      const csrfToken = res.headers.get('qlik-csrf-token');
      // create the enigma.js session:
      const session = enigma.create({
        url: `${config.tenantUri.replace('http', 'ws')}/app/${
          config.appId
        }?qlik-web-integration-id=${
          config.webIntegrationId
        }&qlik-csrf-token=${csrfToken}`,
        schema,
      });
      const global = await session.open();
      // open the app
      return global.openDoc(config.appId);
    }
  • expose login and getApp by adding at the end of the file:
    module.exports = {
      login,
      getApp,
    };

Note: More details about Cross-Site Request Forgery (CSRF) can be found here

Building the app

Now that the project structure is set up including all the required dependencies, you can start building the bookmark manager.

Ensure that the user is signed in and open the app

  • Create the main JavaScript file and call it app.js. Add an asynchronous init function, and then invoke the function.

async function init() {
}

init();

  • import login and getApp functions from the communication module by adding at the very beginning of the file:

import { login, getApp } from './comm';

  • Add the sign in authentication redirect logic and open the app in the init function

async function init() {
  // redirect to login page if user is not logged in
  await login();
  // connects to the websocket and opens the app
  const app = await getApp();

Fetch your app's bookmarks

There are two ways of fetching bookmarks in the Qlik Associative Engine:

app.getBookmarks({
  qOptions: {
    qTypes: ['bookmark'],
    qData: {
      title: '/qMetaDef/title',
      description: '/qMetaDef/description',
      sheetId: '/sheetId',
      selectionFields: '/selectionFields',
      creationDate: '/creationDate',
    }
  }
});
app.createSessionObject({
  qInfo: {
    qId: 'BookmarkList',
    qType: 'BookmarkList',
  },
  qBookmarkListDef: {
    qType: 'bookmark',
    qData: {
      // dynamic data stored by the Qlik Sense client
      title: '/qMetaDef/title',
      description: '/qMetaDef/description',
      sheetId: '/sheetId',
      selectionFields: '/selectionFields',
      creationDate: '/creationDate'
    }
  }
});

In this use case, the advantage of using the Generic object model is that it allows binding to certain events, and the event of interest here is changed. The event is triggered by the Qlik Associative Engine when the state changes from valid to invalid, allowing rendering updates in the front-end.

Both methods require sending a BookmarkList definition as a parameter and in Qlik Sense, bookmarks contain an extra set of data that should be included in the definition (ex: sheetId, creationDate, etc.) to retrieve them.

  • Declare a constant called bookmarkListProps that contains the definition:

const bookmarkListProps = {
  qInfo: {
    qId: 'BookmarkList',
    qType: 'BookmarkList',
  },
  qBookmarkListDef: {
    qType: 'bookmark',
    qData: {
      // dynamic data stored by the Qlik Sense client
      title: '/qMetaDef/title',
      description: '/qMetaDef/description',
      sheetId: '/sheetId',
      selectionFields: '/selectionFields',
      creationDate: '/creationDate',
    },
  },
};

  • Create a session object with the previously declared bookmarkListProps definition, and bind an update function that triggers when the model gets invalidated.

  // creates a session object
  const bookmarkListModel = await app.createSessionObject(bookmarkListProps);

  const update = () => {
  };
  // bind change 'event' to trigger updates
  bookmarkListModel.on('changed', update);
  update();

Render the table

In the previous sections, you created a generic object containing the bookmark list definition and the callback method update triggered by the changed event.

  • In the update function, add:

  const update = () => {
    bookmarkListModel.getLayout().then((layout) => {
      renderTableContent(app, layout);
    });
  };

The getLayout method evaluates an object and returns its properties, in this case, the bookmark list.

The returned layout from the getLayout method should be similar to below.

{
  "qInfo": {},
  "qMeta": {
    "privileges": []
  },
  "qSelectionInfo": {},
  "qBookmarkList": {
    "qItems": []
  }
}
  • Create a top level function called renderTableContent

function renderTableContent(app, layout) {

  • In that function, get HTML element references for the <table> and <tbody> elements, create a new <tbody> element for replacing the old one, and then extract the data from the layout (for now, you only need the qItems array).

function renderTableContent(app, layout) {
  const tableEl = document.querySelector('table');
  const oldTbody = tableEl.querySelector('tbody');
  const tbody = document.createElement('tbody');

  // destruct the layout for data
  const {
    qBookmarkList: { qItems: data },
  } = layout;

  • Now add a forEach loop that creates cells and fills them with the bookmark list data.

  data.forEach((qItem, i) => {
    const row = tbody.insertRow();
    row.setAttribute('bookmark-id', qItem.qInfo.qId);
    row.insertCell().innerHTML = i;
    row.insertCell().innerHTML = `<input type="text" value="${qItem.qData.title}"/>`;
    row.insertCell().innerHTML = qItem.qData.selectionFields;
    row.insertCell().innerHTML = `<textarea>${qItem.qData.description}</textarea>`;
    row.insertCell().innerHTML = new Date(
      qItem.qData.creationDate
    ).toLocaleString();

Note the added attribute called bookmark-id on row element <tr> containing the bookmarkId as value, the <input> element for the title value, and the <textarea> element for the description. These are later used to change the bookmark title and description, but more on this later.

Action buttons

  • In the last cell, create buttons with actions for applying, cloning, removing, and publishing the bookmarks.

    const cell = row.insertCell();

    const buttonApply = document.createElement('button');
    buttonApply.innerHTML = 'apply';
    buttonApply.addEventListener('click', () => {
      app.applyBookmark(qItem.qInfo.qId);
    });

    const buttonClone = document.createElement('button');
    buttonClone.innerHTML = 'clone';
    buttonClone.addEventListener('click', () => {
      app.cloneBookmark(qItem.qInfo.qId);
    });

    const buttonRemove = document.createElement('button');
    buttonRemove.innerHTML = 'remove';
    buttonRemove.addEventListener('click', () => {
      app.destroyBookmark(qItem.qInfo.qId);
    });

    const buttonPublishUnpublish = document.createElement('button');
    buttonPublishUnpublish.innerHTML = qItem.qMeta.published
      ? 'unpublish'
      : 'publish';
    buttonPublishUnpublish.addEventListener('click', () => {
      app
        .getBookmark(qItem.qInfo.qId)
        .then((bookmark) =>
          qItem.qMeta.published ? bookmark.unpublish() : bookmark.publish()
        );
    });

  • Finally, add the buttons to the cell and, after the forEach loop, replace the current body with the newly created one.

    const buttonApply = document.createElement('button');
    buttonApply.innerHTML = 'apply';
    buttonApply.addEventListener('click', () => {
      app.applyBookmark(qItem.qInfo.qId);
    });

    const buttonClone = document.createElement('button');
    buttonClone.innerHTML = 'clone';
    buttonClone.addEventListener('click', () => {
      app.cloneBookmark(qItem.qInfo.qId);
    });

    const buttonRemove = document.createElement('button');
    buttonRemove.innerHTML = 'remove';
    buttonRemove.addEventListener('click', () => {
      app.destroyBookmark(qItem.qInfo.qId);
    });

    const buttonPublishUnpublish = document.createElement('button');
    buttonPublishUnpublish.innerHTML = qItem.qMeta.published
      ? 'unpublish'
      : 'publish';
    buttonPublishUnpublish.addEventListener('click', () => {
      app
        .getBookmark(qItem.qInfo.qId)
        .then((bookmark) =>
          qItem.qMeta.published ? bookmark.unpublish() : bookmark.publish()
        );
    });

    cell.appendChild(buttonApply);
    cell.appendChild(buttonClone);
    cell.appendChild(buttonRemove);
    cell.appendChild(buttonPublishUnpublish);
  });


  tableEl.replaceChild(tbody, oldTbody);

Quick summary of the methods used:

The published state used for "toggling" the publish button of the bookmark can be found nested in the qItem.qMeta object.

Update the title and description of a bookmark

Do you remember the <input> and <textarea> elements added earlier to the title and description cells? It's time to add change event listeners with logic that updates the bookmark's titles and descriptions.

  • Before tableEl.replaceChild(tbody, oldTbody);, add a query selector for all <input> and <textarea> elements. Then in a forEach loop, add the event listener change, which triggers each time the text changes.


  tableEl.querySelectorAll('input,textarea').forEach((inputEl) => {
    inputEl.addEventListener('change', async ({ target }) => {
    });
  });

  tableEl.replaceChild(tbody, oldTbody);

  • Now you get the bookmark ID and model, by adding in the addEventListener callback:

    inputEl.addEventListener('change', async ({ target }) => {
      const bookmarkId = target.parentElement.parentElement.getAttribute(
        'bookmark-id'
      );
      const bookmarkModel = await app.getBookmark(bookmarkId);

Note: you can retrieve the bookmarkId stored in the <tr bookmark-id=""> element as bookmark-id attribute.

  • You can update the bookmark properties with the SetProperties method, but since setProperties overwrites all the properties, you first need to fetch the bookmark properties with the GetProperties method.

The title and description are stored under qMetaDef/title and qMetaDef/description, respectively and since you know that titles use input element and descriptions uses textarea, you can use a conditional ternary operator for setting the value:

      const bookmarkProperties = await bookmarkModel.getProperties();
      bookmarkProperties.qMetaDef[
        target.nodeName === 'INPUT' ? 'title' : 'description'
      ] = target.value;

      bookmarkModel.setProperties(bookmarkProperties);

Save your files and give it a try by running the start script in your terminal:

npm start

Summary

This tutorial hopefully taught you how to interact with bookmarks using enigma.js.

The following files have been created as part of this tutorial.

index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Bookmark Manager</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css"
    />
    <script type="text/javascript" src="app.js"></script>
  </head>
  <body>
    <div class="jumbotron text-center">
      <h1>Bookmark Manager.</h1>
      <p>This a web app example showcasing bookmarks with enigma.js.</p>
    </div>
    <div class="container">
      <table class="table">
        <thead class="thead-dark">
          <tr>
            <th scope="col">#</th>
            <th scope="col">title</th>
            <th scope="col">fields</th>
            <th scope="col">sheetId</th>
            <th scope="col">creationDate</th>
            <th scope="col">actions</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
    </div>
  </body>
</html>

comm.js

import enigma from 'enigma.js';
import schema from 'enigma.js/schemas/12.20.0.json';

const config = {
  tenantUri: 'https://your-tenant.us.qlikcloud.com',
  webIntegrationId: 'yourWebIntegrationId',
  appId: 'yourAppId',
};

function request(path) {
  return fetch(config.tenantUri + path, {
    mode: 'cors', // cors must be enabled
    credentials: 'include', // credentials must be included
    headers: {
      // needed in order to whitelist your domain
      'qlik-web-integration-id': config.webIntegrationId,
    },
  });
}

async function login() {
  // loginUrl will be used to redirect to your tenant log in page,
  // and once signed in, redirect back to your web-app
  const loginUrl = new URL(`${config.tenantUri}/login`);
  loginUrl.searchParams.append('returnto', window.location.href);
  loginUrl.searchParams.append(
    'qlik-web-integration-id',
    config.webIntegrationId
  );
  // fetches the current logged in user's metadata and will return
  // true if current user is logged in, respectively false if not
  const loggedIn = await request('/api/v1/users/me').then(
    (response) => response.status === 200
  );
  if (!loggedIn) {
    window.location.href = loginUrl;
  }
}

async function getApp() {
  // fetch the CSRF token required for opening the websocket
  const res = await request('/api/v1/csrf-token', false);
  const csrfToken = res.headers.get('qlik-csrf-token');
  // create the enigma.js session:
  const session = enigma.create({
    url: `${config.tenantUri.replace('http', 'ws')}/app/${
      config.appId
    }?qlik-web-integration-id=${
      config.webIntegrationId
    }&qlik-csrf-token=${csrfToken}`,
    schema,
  });
  const global = await session.open();
  // open the app
  return global.openDoc(config.appId);
}

module.exports = {
  login,
  getApp,
};

app.js

// eslint-disable-next-line import/named
import { login, getApp } from './comm';

const bookmarkListProps = {
  qInfo: {
    qId: 'BookmarkList',
    qType: 'BookmarkList',
  },
  qBookmarkListDef: {
    qType: 'bookmark',
    qData: {
      // dynamic data stored by the Qlik Sense client
      title: '/qMetaDef/title',
      description: '/qMetaDef/description',
      sheetId: '/sheetId',
      selectionFields: '/selectionFields',
      creationDate: '/creationDate',
    },
  },
};

function renderTableContent(app, layout) {
  const tableEl = document.querySelector('table');
  const oldTbody = tableEl.querySelector('tbody');
  const tbody = document.createElement('tbody');

  // destruct the layout for data
  const {
    qBookmarkList: { qItems: data },
  } = layout;

  data.forEach((qItem, i) => {
    const row = tbody.insertRow();
    row.setAttribute('bookmark-id', qItem.qInfo.qId);
    row.insertCell().innerHTML = i;
    row.insertCell().innerHTML = `<input type="text" value="${qItem.qData.title}"/>`;
    row.insertCell().innerHTML = qItem.qData.selectionFields;
    row.insertCell().innerHTML = `<textarea>${qItem.qData.description}</textarea>`;
    row.insertCell().innerHTML = new Date(
      qItem.qData.creationDate
    ).toLocaleString();

    const cell = row.insertCell();

    const buttonApply = document.createElement('button');
    buttonApply.innerHTML = 'apply';
    buttonApply.addEventListener('click', () => {
      app.applyBookmark(qItem.qInfo.qId);
    });

    const buttonClone = document.createElement('button');
    buttonClone.innerHTML = 'clone';
    buttonClone.addEventListener('click', () => {
      app.cloneBookmark(qItem.qInfo.qId);
    });

    const buttonRemove = document.createElement('button');
    buttonRemove.innerHTML = 'remove';
    buttonRemove.addEventListener('click', () => {
      app.destroyBookmark(qItem.qInfo.qId);
    });

    const buttonPublishUnpublish = document.createElement('button');
    buttonPublishUnpublish.innerHTML = qItem.qMeta.published
      ? 'unpublish'
      : 'publish';
    buttonPublishUnpublish.addEventListener('click', () => {
      app
        .getBookmark(qItem.qInfo.qId)
        .then((bookmark) =>
          qItem.qMeta.published ? bookmark.unpublish() : bookmark.publish()
        );
    });

    cell.appendChild(buttonApply);
    cell.appendChild(buttonClone);
    cell.appendChild(buttonRemove);
    cell.appendChild(buttonPublishUnpublish);
  });

  tableEl.querySelectorAll('input,textarea').forEach((inputEl) => {
    inputEl.addEventListener('change', async ({ target }) => {
      const bookmarkId = target.parentElement.parentElement.getAttribute(
        'bookmark-id'
      );
      const bookmarkModel = await app.getBookmark(bookmarkId);
      const bookmarkProperties = await bookmarkModel.getProperties();
      bookmarkProperties.qMetaDef[
        target.nodeName === 'INPUT' ? 'title' : 'description'
      ] = target.value;

      bookmarkModel.setProperties(bookmarkProperties);
    });
  });

  tableEl.replaceChild(tbody, oldTbody);
}

async function init() {
  // redirect to login page if user is not logged in
  await login();
  // connects to the websocket and opens the app
  const app = await getApp();

  // creates a session object
  const bookmarkListModel = await app.createSessionObject(bookmarkListProps);

  // callback function for updating rendering the table
  const update = () => {
    bookmarkListModel.getLayout().then((layout) => {
      renderTableContent(app, layout);
    });
  };

  // bind change 'event' to trigger updates
  bookmarkListModel.on('changed', update);
  update();
}

init();

package.json

{
  "name": "bookmark-manager",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "parcel index.html --open"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "parcel-bundler": "1.12.3"
  },
  "browserslist": [
    "last 1 Chrome version"
  ],
  "dependencies": {
    "enigma.js": "^2.7.2"
  }
}

If you have feedback, questions, comments, criticism, head over to the Qlik Community and discuss.

Was this page helpful?