Interact with bookmarks
Note: Where possible, use qlik-embed and qlik-api rather than this framework. Please refer to this guide to learn how to manage bookmarks using qlik-embed.
Third-party cookies: This tutorial leverages cookies for auth, which are blocked by some browser vendors. Please use an OAuth solution where possible, which doesn’t leverage cookies.
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.
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 development 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-managercd bookmark-manager
# initialize the project without having it ask any questions# this creates a `package.json` in the root of your directorynpm init -y
# install the required dependenciesnpm i -S enigma.jsnpm 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 development 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; }}
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');
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 fileconst enigma = require('enigma.js');const schema = require('enigma.js/schemas/12.20.0.json'); - add the
getApp
methodconst 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 appreturn global.openDoc(config.appId);}module.exports = {login,getApp,}; - expose
login
andgetApp
by adding at the end of the file:
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.
const update = () => {
- import
login
andgetApp
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
const update = () => { bookmarkListModel.getLayout().then((layout) => { renderTableContent(app, layout); }); };
Fetch your app’s bookmarks
There are two ways of fetching bookmarks in the Qlik Associative Engine:
- by using the GetBookmarks method example:
app.getBookmarks({ qOptions: { qTypes: ["bookmark"], qData: { title: "/qMetaDef/title", description: "/qMetaDef/description", sheetId: "/sheetId", selectionFields: "/selectionFields", creationDate: "/creationDate", }, },});
- or by creating a GenericObject with the CreateSessionObject:
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 anupdate
function that triggers when the model gets invalidated.
// 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:
init();
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 theqItems
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();
const cell = row.insertCell();
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 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); });
- Finally, add the buttons to the cell and, after the forEach loop, replace the current body with the newly created one.
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; // creates a session object const bookmarkListModel = await app.createSessionObject(bookmarkListProps);
Quick summary of the methods used:
- ApplyBookmark (alternatively you can use GenericBookmark Apply),
- CloneBookmark for duplicating a bookmark,
- DestroyBookmark for removing a bookmark,
- GenericBookmark Publish for publishing a bookmark
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 aforEach
loop, add the event listenerchange
, which triggers each time the text changes.
bookmarkProperties.qMetaDef[target.nodeName === 'INPUT' ? 'title' : 'description'] = target.value;
bookmarkModel.setProperties(bookmarkProperties); const app = await getApp();
// creates a session object const bookmarkListModel = await app.createSessionObject(bookmarkListProps);
- Now you get the bookmark ID and model, by adding in the
addEventListener
callback:
bookmarkModel.setProperties(bookmarkProperties); }); });
tableEl.replaceChild(tbody, oldTbody);
Note: You can retrieve the
bookmarkId
stored in the<tr bookmark-id="">
element as thebookmark-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:
}
async function init() { // redirect to login page if user is not logged in await login(); // connects to the websocket and opens the app
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
const enigma = require('enigma.js');const schema = require('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/namedimport { 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" }}