├── book.json ├── .npmignore ├── test ├── e2e │ ├── config.json │ ├── test-model │ │ └── iris.qvf │ ├── e2e.spec.js │ ├── field.test.js │ ├── global.test.js │ ├── connectSession.test.js │ ├── genericBookmark.test.js │ ├── suspend.test.js │ ├── closeSession.test.js │ ├── utilityOperators.test.js │ ├── genericObject.test.js │ ├── doc.test.js │ ├── notification.test.js │ └── delta.test.js ├── util │ ├── isObservable.js │ ├── mock-qix-engine.js │ └── create-container.js └── unit │ ├── $fromQix.spec.js │ ├── connectSession.spec.js │ ├── Session.spec.js │ └── Handle.spec.js ├── docs ├── basics │ ├── closing-a-session.md │ ├── notifications.md │ ├── suspending-invalidations.md │ ├── connecting.md │ ├── delta.md │ ├── handle-invalidations.md │ ├── reusing-handles.md │ ├── making-api-calls.md │ └── shortcut-operators.md ├── recipes │ ├── connect.md │ ├── open-an-app.md │ ├── engine-version.md │ ├── shortcut-operators.md │ ├── calc-time.md │ ├── read-app-props.md │ ├── multiple-global-calls.md │ ├── make-selections-on-app-open.md │ ├── gen-obj-value.md │ ├── current-selections.md │ ├── set-app-props.md │ ├── get-data-page-on-invalid.md │ ├── batch-invalidations.md │ ├── make-a-selection.md │ ├── toggle-sessions.md │ ├── loop-selections.md │ ├── get-pages-in-sequence.md │ ├── get-pages-in-parallel.md │ ├── make-lb-selections.md │ ├── search-lb.md │ ├── offline.md │ └── apply-patches.md ├── README.md ├── introduction │ ├── rxq-vs-enigma.md │ ├── why-rx.md │ └── core-concepts.md └── SUMMARY.md ├── src ├── operators │ ├── qAsk.js │ ├── qAskReplay.js │ ├── invalidations.js │ └── suspendUntilCompleted.js ├── util │ ├── index.js │ ├── map-qix-return.js │ ├── connectWS.js │ ├── qix-connect-options.js │ └── qix-connect-string.js ├── connect │ └── connectSession.js ├── handle.js ├── index.js ├── session.js └── schema │ └── schema.js ├── .gitignore ├── index.js ├── CHANGELOG.md ├── scripts ├── compile_esm2015.js ├── compile_esm5.js ├── compile_cjs.js ├── make-packages.js ├── build-qix-methods.js └── build-browser.js ├── package.json ├── README.md └── LICENSE /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "./docs" 3 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | rxqap-build.zip 3 | sandbox 4 | .tmp -------------------------------------------------------------------------------- /test/e2e/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": "9079", 3 | "image": "qlikcore/engine:12.329.0" 4 | } 5 | -------------------------------------------------------------------------------- /test/e2e/test-model/iris.qvf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skokenes/RxQ/HEAD/test/e2e/test-model/iris.qvf -------------------------------------------------------------------------------- /test/util/isObservable.js: -------------------------------------------------------------------------------- 1 | module.exports = toCheck => typeof toCheck["@@observable"] === "function"; 2 | -------------------------------------------------------------------------------- /docs/basics/closing-a-session.md: -------------------------------------------------------------------------------- 1 | # Closing a Session 2 | Sessions can be closed using the `session.close()` function. This function will close the WebSocket and complete all Observables within RxQ. -------------------------------------------------------------------------------- /src/operators/qAsk.js: -------------------------------------------------------------------------------- 1 | import { switchMap } from "rxjs/operators"; 2 | 3 | const qAsk = (methodname, ...params) => handle$ => 4 | handle$.pipe(switchMap(handle => handle.ask(methodname, ...params))); 5 | 6 | export default qAsk; 7 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | import connectWS from "./connectWS.js"; 2 | import qixConnectOptions from "./qix-connect-options.js"; 3 | import qixConnectString from "./qix-connect-string.js"; 4 | 5 | export { 6 | connectWS, 7 | qixConnectOptions, 8 | qixConnectString 9 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | src/global 4 | src/doc 5 | src/genericbookmark 6 | src/field 7 | src/genericderivedfields 8 | src/genericdimension 9 | src/genericmeasure 10 | src/genericobject 11 | src/genericvariable 12 | src/variable 13 | _book 14 | .tmp 15 | .DS_Store -------------------------------------------------------------------------------- /src/operators/qAskReplay.js: -------------------------------------------------------------------------------- 1 | import qAsk from "./qAsk"; 2 | import { publishReplay, refCount } from "rxjs/operators"; 3 | 4 | const qAskReplay = (methodname, ...params) => handle$ => 5 | handle$.pipe( 6 | qAsk(methodname, ...params), 7 | publishReplay(1), 8 | refCount() 9 | ); 10 | 11 | export default qAskReplay; 12 | -------------------------------------------------------------------------------- /src/operators/invalidations.js: -------------------------------------------------------------------------------- 1 | import { switchMap, startWith } from "rxjs/operators"; 2 | 3 | const invalidations = (startWithInvalidation = false) => handle$ => 4 | handle$.pipe( 5 | switchMap( 6 | handle => 7 | startWithInvalidation 8 | ? startWith(handle)(handle.invalidated$) 9 | : handle.invalidated$ 10 | ) 11 | ); 12 | 13 | export default invalidations; 14 | -------------------------------------------------------------------------------- /src/operators/suspendUntilCompleted.js: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | 3 | export default session => src$ => { 4 | return Observable.create(observer => { 5 | session.suspend(); 6 | src$.subscribe({ 7 | next: n => observer.next(n), 8 | error: err => observer.error(err), 9 | complete: () => { 10 | session.unsuspend(); 11 | observer.complete(); 12 | } 13 | }); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/connect/connectSession.js: -------------------------------------------------------------------------------- 1 | import Session from "../session"; 2 | import { defer } from "rxjs"; 3 | 4 | export default function connectSession(config) { 5 | const session = new Session(config); 6 | 7 | return { 8 | global$: session.global(), 9 | notifications$: session.notifications$, 10 | close: () => session.close(), 11 | suspend: () => session.suspended$.next(true), 12 | unsuspend: () => session.suspended$.next(false) 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Polyfills 2 | import "core-js/es6/symbol"; // polyfill symbol to enable iterators in IE 3 | // import "core-js/fn/function/name"; // may be able to remove this with refactor 4 | import "regenerator-runtime/runtime"; 5 | 6 | // Observable Factories 7 | import * as src from "./src/index"; 8 | 9 | import pack from "./package.json"; 10 | 11 | const baseObj = { 12 | version: pack.version, 13 | qixVersion: pack["qix-version"] 14 | }; 15 | 16 | const RxQ = Object.assign(baseObj, src); 17 | 18 | export default RxQ; 19 | -------------------------------------------------------------------------------- /docs/recipes/connect.md: -------------------------------------------------------------------------------- 1 | # Connect to an Engine 2 | [Code Sandbox](https://codesandbox.io/embed/k2wz9px11v) 3 | 4 | ```javascript 5 | // Import the connectSession function 6 | import { connectSession } from "rxq"; 7 | 8 | // Define the configuration for your session 9 | const config = { 10 | host: "sense.axisgroup.com", 11 | isSecure: true 12 | }; 13 | 14 | // Call connectSession with the config to produce a Session object 15 | const session = connectSession(config); 16 | 17 | // Subscribe to the Global Handle Observable of the Session 18 | session.global$.subscribe(console.log); 19 | ``` -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # RxQ 2 | **RxQ** is a JavaScript library that provides utilities to link [**RxJS**](https://github.com/ReactiveX/rxjs), a JavaScript implementation of Rx, with the Qlik Associative Engine (QAE). RxQ can be used to build complex, interactive solutions on top of QAE using the reactive programming paradigm. The code repository can be found at [https://github.com/axisgroup/rxq](https://github.com/axisgroup/rxq). RxQ can be used in projects via npm like so: 3 | ``` 4 | $ npm install rxq 5 | ``` 6 | 7 | These docs are for RxQ v2.0.1. For v1 docs, [go here.](https://opensrc.axisgroup.com/rxq/docs-v1) -------------------------------------------------------------------------------- /src/util/map-qix-return.js: -------------------------------------------------------------------------------- 1 | import Handle from "../handle.js"; 2 | import { map } from "rxjs/operators"; 3 | 4 | export default (handle, returnParam) => src$ => 5 | src$.pipe( 6 | map(r => { 7 | var hasQType = 8 | r.hasOwnProperty("qReturn") && r.qReturn.hasOwnProperty("qType"); 9 | 10 | if (hasQType) { 11 | var qClass = r.qReturn.qType; 12 | return new Handle(handle.session, r.qReturn.qHandle, qClass); 13 | } else if (returnParam) { 14 | return r[returnParam]; 15 | } else { 16 | return r; 17 | } 18 | }) 19 | ); 20 | -------------------------------------------------------------------------------- /test/util/mock-qix-engine.js: -------------------------------------------------------------------------------- 1 | var { WebSocket, Server } = require("mock-socket"); 2 | 3 | function mockEngine() { 4 | var stamp = Date.now(); 5 | var url = `ws://127.0.0.1:4848/${stamp}`; 6 | const server = new Server(url); 7 | const ws = new WebSocket(url); 8 | 9 | server.on("message", msg => { 10 | const { id, method } = JSON.parse(msg); 11 | 12 | server.send( 13 | JSON.stringify({ 14 | jsonrpc: "2.0", 15 | id: id, 16 | result: { 17 | foo: "bar", 18 | method 19 | } 20 | }) 21 | ); 22 | }); 23 | 24 | return { server, ws }; 25 | } 26 | 27 | module.exports = mockEngine; 28 | -------------------------------------------------------------------------------- /src/handle.js: -------------------------------------------------------------------------------- 1 | import { defer } from "rxjs"; 2 | import { filter, mapTo, publish, refCount } from "rxjs/operators"; 3 | 4 | export default class Handle { 5 | constructor(session, handle, qClass) { 6 | this.session = session; 7 | this.handle = handle; 8 | this.qClass = qClass; 9 | 10 | this.invalidated$ = session.changes$.pipe( 11 | filter(f => f.indexOf(handle) > -1), 12 | mapTo(this), 13 | publish(), 14 | refCount() 15 | ); 16 | } 17 | 18 | ask(method, ...args) { 19 | return defer(() => 20 | this.session.ask({ 21 | method: method, 22 | handle: this.handle, 23 | params: args.filter(arg => typeof arg !== "undefined"), 24 | qClass: this.qClass 25 | }) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/basics/notifications.md: -------------------------------------------------------------------------------- 1 | # Notifications 2 | RxQ provides an Observable `session.notifications$`. This Observable provides messages related to the inner workings of RxQ and the Engine session. It is especially useful for debugging purposes. It sends messages in the format: 3 | ``` 4 | { 5 | type: a string indicating the type of notification 6 | data: any data associated with the notification 7 | } 8 | ``` 9 | 10 | The following type of notifications are emitted: 11 | * `"traffic:sent"` – messages received in the WebSocket 12 | * `"traffic:received"` – messages sent through the WebSocket 13 | * `"traffic:change"` – lists of change handles sent by the Engine 14 | * `"traffic:suspend-status"` – the suspense state of the session 15 | * `"socket:close"` – the WebSocket event when the socket is closed -------------------------------------------------------------------------------- /docs/recipes/open-an-app.md: -------------------------------------------------------------------------------- 1 | # Open an App 2 | [Code Sandbox](https://codesandbox.io/embed/54mj0myvlx) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { shareReplay, switchMap } from "rxjs/operators"; 7 | 8 | // Define the configuration for your session 9 | const config = { 10 | host: "sense.axisgroup.com", 11 | isSecure: true 12 | }; 13 | 14 | // Connect the session and share the Global handle 15 | const session = connectSession(config); 16 | const global$ = session.global$; 17 | 18 | // Open an app and share the app handle 19 | const app$ = global$.pipe( 20 | switchMap(h => h.ask(OpenDoc, "aae16724-dfd9-478b-b401-0d8038793adf")), 21 | shareReplay(1) 22 | ); 23 | 24 | // Log the app handle to the console 25 | app$.subscribe(console.log); 26 | ``` -------------------------------------------------------------------------------- /docs/recipes/engine-version.md: -------------------------------------------------------------------------------- 1 | # Get the Engine Version 2 | [Code Sandbox](https://codesandbox.io/embed/6j47717pz3) 3 | ```javascript 4 | // Import the connectSession function, engineVersion function, and switchMap operator 5 | import { connectSession } from "rxq"; 6 | import { EngineVersion } from "rxq/Global"; 7 | import { switchMap } from "rxjs/operators"; 8 | 9 | // Define the configuration for your session 10 | const config = { 11 | host: "sense.axisgroup.com", 12 | isSecure: true 13 | }; 14 | 15 | // Connect the session 16 | const session = connectSession(config); 17 | const global$ = session.global$; 18 | 19 | // Get the engineVersion 20 | const engVer$ = global$.pipe( 21 | switchMap(h => h.ask(EngineVersion)) 22 | ); 23 | 24 | // Write the engine version to the DOM 25 | engVer$.subscribe(response => { 26 | document.querySelector("#ver").innerHTML = response.qComponentVersion; 27 | }); 28 | ``` -------------------------------------------------------------------------------- /src/util/connectWS.js: -------------------------------------------------------------------------------- 1 | import connectUrl from "./qix-connect-string"; 2 | import connectOptions from "./qix-connect-options"; 3 | 4 | export default function connectWS(config) { 5 | const IS_NODE = 6 | typeof process !== "undefined" && 7 | Object.prototype.toString.call(global.process) === "[object process]"; 8 | 9 | let _WebSocket; 10 | 11 | if (IS_NODE) { 12 | try { 13 | _WebSocket = require("ws"); 14 | } catch (e) {} 15 | } else { 16 | try { 17 | _WebSocket = WebSocket; 18 | } catch (e) {} 19 | } 20 | 21 | const url = typeof config.url === "string" ? config.url : connectUrl(config); 22 | const options = connectOptions(config); 23 | 24 | if (typeof _WebSocket === "function") { 25 | const ws = IS_NODE 26 | ? new _WebSocket(url, null, options) 27 | : new _WebSocket(url); 28 | return ws; 29 | } else { 30 | throw new Error("WebSocket is not defined"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docs/recipes/shortcut-operators.md: -------------------------------------------------------------------------------- 1 | # Using the Shortcut Operators for Layout Streams 2 | [Code Sandbox](https://codesandbox.io/embed/71oj6rj10x) 3 | ```javascript 4 | import { connectSession, qAsk, qAskReplay, invalidations } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout } from "rxq/GenericObject"; 8 | 9 | 10 | const config = { 11 | host: "sense.axisgroup.com", 12 | isSecure: true, 13 | appname: "aae16724-dfd9-478b-b401-0d8038793adf" 14 | }; 15 | 16 | const session = connectSession(config); 17 | const global$ = session.global$; 18 | 19 | const app$ = global$.pipe(qAskReplay(OpenDoc, config.appname)); 20 | 21 | const object$ = app$.pipe( 22 | qAskReplay(CreateSessionObject, { 23 | qInfo: { 24 | qType: "object" 25 | }, 26 | foo: "bar" 27 | }) 28 | ); 29 | 30 | const layouts$ = object$.pipe(invalidations(true), qAsk(GetLayout)); 31 | 32 | layouts$.subscribe(console.log); 33 | 34 | ``` -------------------------------------------------------------------------------- /docs/recipes/calc-time.md: -------------------------------------------------------------------------------- 1 | # Calculate the response time of the API 2 | [Code Sandbox](https://codesandbox.io/embed/3kvj773np5) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { map, switchMap } from "rxjs/operators"; 7 | 8 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 9 | 10 | // Define the configuration for your session 11 | const config = { 12 | host: "sense.axisgroup.com", 13 | isSecure: true, 14 | appname 15 | }; 16 | 17 | // Connect the session and share the Global handle 18 | const session = connectSession(config); 19 | const global$ = session.global$; 20 | 21 | // Calculate the time it takes to open an app 22 | const appOpenTime$ = global$.pipe( 23 | switchMap(h => { 24 | const start = Date.now(); 25 | return h.ask(OpenDoc, appname).pipe( 26 | map(() => Date.now() - start) 27 | ); 28 | }) 29 | ); 30 | 31 | appOpenTime$.subscribe(time => { 32 | document.querySelector("#time").innerHTML = time; 33 | }); 34 | ``` -------------------------------------------------------------------------------- /test/e2e/e2e.spec.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var testConnect = require("./connectSession.test.js"); 6 | var testGlobal = require("./global.test.js"); 7 | const testDoc = require("./doc.test.js"); 8 | const testGenericObject = require("./genericObject.test.js"); 9 | const testField = require("./field.test.js"); 10 | const testGenericBookmark = require("./genericBookmark.test.js"); 11 | const testSuspend = require("./suspend.test.js"); 12 | const testNotification = require("./notification.test.js"); 13 | const testDelta = require("./delta.test.js"); 14 | const testClose = require("./closeSession.test.js"); 15 | const testUtilityOperators = require("./utilityOperators.test.js"); 16 | 17 | describe("Engine E2E test", function() { 18 | testConnect(); 19 | testGlobal(); 20 | testDoc(); 21 | testGenericObject(); 22 | testField(); 23 | testGenericBookmark(); 24 | testSuspend(); 25 | testNotification(); 26 | testDelta(); 27 | testClose(); 28 | testUtilityOperators(); 29 | }); 30 | -------------------------------------------------------------------------------- /test/unit/$fromQix.spec.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | const expect = chai.expect; 3 | 4 | var { Observable } = require("rxjs"); 5 | var { shareReplay } = require("rxjs/operators"); 6 | const mockEngine = require("../util/mock-qix-engine.js"); 7 | 8 | // RxQ 9 | var { connectSession } = require("../../dist"); 10 | var global = require("../../dist/global"); 11 | 12 | describe("Observable from Qix Calls", function() { 13 | // Mock Engine for Testing 14 | var { server, ws } = mockEngine(); 15 | var config = { 16 | ws 17 | }; 18 | 19 | const session = connectSession(config); 20 | var eng$ = session.global$.pipe(shareReplay(1)); 21 | 22 | describe("Global", function() { 23 | it("should have an EngineVersion enum", function() { 24 | expect(global).to.have.property("EngineVersion"); 25 | }); 26 | 27 | describe("EngineVersion", function() { 28 | it("should be a string", function() { 29 | expect(global.EngineVersion).to.be.a("string"); 30 | }); 31 | }); 32 | }); 33 | 34 | after(function() { 35 | server.stop(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as Field from "./Field"; 2 | import * as Variable from "./Variable"; 3 | import * as GenericObject from "./GenericObject"; 4 | import * as GenericDimension from "./GenericDimension"; 5 | import * as GenericBookmark from "./GenericBookmark"; 6 | import * as GenericVariable from "./GenericVariable"; 7 | import * as GenericMeasure from "./GenericMeasure"; 8 | import * as Doc from "./Doc"; 9 | import * as Global from "./Global"; 10 | import connectSession from "./connect/connectSession"; 11 | import qAsk from "./operators/qAsk"; 12 | import qAskReplay from "./operators/qAskReplay"; 13 | import invalidations from "./operators/invalidations"; 14 | import suspendUntilCompleted from "./operators/suspendUntilCompleted"; 15 | 16 | export { Field }; 17 | export { Variable }; 18 | export { GenericObject }; 19 | export { GenericDimension }; 20 | export { GenericBookmark }; 21 | export { GenericVariable }; 22 | export { GenericMeasure }; 23 | export { Doc }; 24 | export { Global }; 25 | export { connectSession }; 26 | export { qAsk }; 27 | export { qAskReplay }; 28 | export { invalidations }; 29 | export { suspendUntilCompleted }; 30 | -------------------------------------------------------------------------------- /docs/recipes/read-app-props.md: -------------------------------------------------------------------------------- 1 | # Read app properties 2 | [Code Sandbox](https://codesandbox.io/embed/o4jjp82zwy) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { GetAppProperties } from "rxq/Doc"; 7 | import { shareReplay, switchMap } from "rxjs/operators"; 8 | 9 | // Define the configuration for your session 10 | const config = { 11 | host: "sense.axisgroup.com", 12 | isSecure: true 13 | }; 14 | 15 | // Connect the session and share the Global handle 16 | const session = connectSession(config); 17 | const global$ = session.global$; 18 | 19 | // Open an app and share the app handle 20 | const app$ = global$.pipe( 21 | switchMap(h => h.ask(OpenDoc, "aae16724-dfd9-478b-b401-0d8038793adf")), 22 | shareReplay(1) 23 | ); 24 | 25 | // Get the app properties 26 | const appProps$ = app$.pipe( 27 | switchMap(h => h.ask(GetAppProperties)) 28 | ); 29 | 30 | // Write the app title and modified date to the DOM 31 | appProps$.subscribe(props => { 32 | document.querySelector("#content").innerHTML = `The app ${props.qTitle} was last modified at ${props.modifiedDate}`; 33 | }); 34 | ``` -------------------------------------------------------------------------------- /src/util/qix-connect-options.js: -------------------------------------------------------------------------------- 1 | export default function(config) { 2 | var cfg = {}; 3 | 4 | // Update prefix 5 | var prefix = config.prefix ? config.prefix : '/'; 6 | 7 | if (prefix.slice(0, 1) !== '/') { 8 | prefix = '/' + prefix; 9 | }; 10 | if (prefix.split('').pop() !== '/') { 11 | prefix = prefix + '/'; 12 | }; 13 | 14 | // Copy properties + defaults 15 | if (config) { 16 | cfg.mark = config.mark; 17 | cfg.port = config.port; 18 | cfg.appname = config.appname || false; 19 | cfg.host = config.host; 20 | cfg.prefix = prefix; 21 | cfg.origin = config.origin; 22 | cfg.isSecure = config.isSecure; 23 | cfg.rejectUnauthorized = config.rejectUnauthorized; 24 | cfg.headers = config.headers || {}; 25 | cfg.ticket = config.ticket || false; 26 | cfg.key = config.key || null; 27 | cfg.cert = config.cert || null; 28 | cfg.ca = config.ca || null; 29 | cfg.pfx = config.pfx || null; 30 | cfg.passphrase = config.passphrase || null; 31 | cfg.identity = config.identity; 32 | } 33 | 34 | return cfg; 35 | }; -------------------------------------------------------------------------------- /docs/recipes/multiple-global-calls.md: -------------------------------------------------------------------------------- 1 | # Multiple Global Calls 2 | [Code Sandbox](https://codesandbox.io/embed/lpzn20399q) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { EngineVersion, GetDocList } from "rxq/Global"; 6 | import { shareReplay, switchMap } from "rxjs/operators"; 7 | 8 | // Define the configuration for your session 9 | const config = { 10 | host: "sense.axisgroup.com", 11 | isSecure: true 12 | }; 13 | 14 | // Connect the session and share the Global handle 15 | const session = connectSession(config); 16 | const global$ = session.global$; 17 | 18 | // Get the engineVersion 19 | const engVer$ = global$.pipe( 20 | switchMap(h => h.ask(EngineVersion)) 21 | ); 22 | 23 | // Get the Doc List 24 | const doclist$ = global$.pipe( 25 | switchMap(h => h.ask(GetDocList)) 26 | ); 27 | 28 | // Write the engine version to the DOM 29 | engVer$.subscribe(response => { 30 | document.querySelector("#ver").innerHTML = response.qComponentVersion; 31 | }); 32 | 33 | // Write the doc list to the DOM 34 | doclist$.subscribe(dl => { 35 | document.querySelector("#content").innerHTML += dl.map(doc => `
  • ${doc.qDocName}
  • `).join(""); 36 | }); 37 | ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.4 (2019-03-07) 2 | 3 | ### Bug Fixes 4 | * Fix for issue where delta mode would mutate data when applying patches Fixes [#51](https://github.com/axisgroup/RxQ/issues/51) 5 | 6 | ## 2.0.3 (2019-01-29) 7 | 8 | ### Bug Fixes 9 | * Fix for issue where engine could not be connectly directed to if an appname wasn't supplied Fixes [#61](https://github.com/axisgroup/RxQ/issues/61) 10 | 11 | ## 2.0.2 (2018-10-29) 12 | 13 | ### Bug Fixes 14 | * Replaced shareReplay with publishReplay, refCount in qAskReplay. Closes [#58](https://github.com/axisgroup/RxQ/issues/58) 15 | * handle.ask() now filters out undefined arguments, rather than sending nulls to the Engine. Closes [#57](https://github.com/axisgroup/RxQ/issues/57) 16 | 17 | ### Features 18 | * Added `url` parameter for config file that allows users to specify the WebSocket url to connect to. Closes [#56](https://github.com/axisgroup/RxQ/issues/56) 19 | * Updated generated qix methods to Engine API 12.260.0 20 | 21 | ## 2.0.1 (2018-07-26) 22 | 23 | ### Bug Fixes 24 | * Removed Handle instance checking for `qAsk`, `qInvalidations` operators as temporary fix; was causing issues in other projects and isn't really necessary. Closes [#55](https://github.com/axisgroup/RxQ/issues/55) 25 | * Fixed issue where not defining an appname in the connect config would cause an invalid URL 26 | 27 | ### Code Refactoring 28 | * Cleaned up some dead code 29 | * Updated README with testing information -------------------------------------------------------------------------------- /docs/recipes/make-selections-on-app-open.md: -------------------------------------------------------------------------------- 1 | # Make Selections on App Open 2 | [Code Sandbox](https://codesandbox.io/embed/18nnwv6xol) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { GetField } from "rxq/Doc"; 7 | import { Select } from "rxq/Field"; 8 | import { forkJoin } from "rxjs"; 9 | import { mapTo, shareReplay, switchMap } from "rxjs/operators"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 12 | 13 | // Define the configuration for your session 14 | const config = { 15 | host: "sense.axisgroup.com", 16 | isSecure: true, 17 | appname 18 | }; 19 | 20 | // Connect the session and share the Global handle 21 | const session = connectSession(config); 22 | const global$ = session.global$; 23 | 24 | // Open an app, get the handle, make a few selections, and then multicast it 25 | const app$ = global$.pipe( 26 | switchMap(h => h.ask(OpenDoc, appname)), 27 | switchMap(h => { 28 | const defaultSelection1$ = h.ask(GetField, "species").pipe( 29 | switchMap(fldH => fldH.ask(Select, "setosa")) 30 | ); 31 | 32 | const defaultSelection2$ = h.ask(GetField, "petal_length").pipe( 33 | switchMap(fldH => fldH.ask(Select, ">2")) 34 | ); 35 | 36 | return forkJoin(defaultSelection1$, defaultSelection2$).pipe( 37 | mapTo(h) 38 | ); 39 | 40 | }), 41 | shareReplay(1) 42 | ); 43 | 44 | app$.subscribe(console.log); 45 | ``` -------------------------------------------------------------------------------- /docs/recipes/gen-obj-value.md: -------------------------------------------------------------------------------- 1 | # Calculate a value with a Generic Object 2 | [Code Sandbox](https://codesandbox.io/embed/qz3y4jvm4j) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout } from "rxq/GenericObject"; 8 | import { shareReplay, switchMap } from "rxjs/operators"; 9 | 10 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 11 | 12 | // Define the configuration for your session 13 | const config = { 14 | host: "sense.axisgroup.com", 15 | isSecure: true, 16 | appname 17 | }; 18 | 19 | // Connect the session and share the Global handle 20 | const session = connectSession(config); 21 | const global$ = session.global$; 22 | 23 | // Open an app and share the app handle 24 | const app$ = global$.pipe( 25 | switchMap(h => h.ask(OpenDoc, appname)), 26 | shareReplay(1) 27 | ); 28 | 29 | // Create a Generic Object with a formula 30 | const obj$ = app$.pipe( 31 | switchMap(h => h.ask(CreateSessionObject, { 32 | "qInfo": { 33 | "qType": "my-object" 34 | }, 35 | "myValue": { 36 | "qValueExpression": "=avg(petal_length)" 37 | } 38 | })), 39 | shareReplay(1) 40 | ); 41 | 42 | // Get the layout of the Generic Object to calculate the value 43 | const value$ = obj$.pipe( 44 | switchMap(h => h.ask(GetLayout)) 45 | ); 46 | 47 | // Write the value to the DOM 48 | value$.subscribe(layout => { 49 | document.querySelector("#val").innerHTML = layout.myValue; 50 | }); 51 | ``` -------------------------------------------------------------------------------- /test/util/create-container.js: -------------------------------------------------------------------------------- 1 | var Docker = require("dockerode"); 2 | var { Observable } = require("rxjs"); 3 | var { publishReplay, refCount } = require("rxjs/operators"); 4 | var path = require("path"); 5 | 6 | var modelFolder = "/test/e2e/test-model"; 7 | 8 | function createContainer(image, port) { 9 | // launch a new container 10 | var container$ = Observable.create(observer => { 11 | var docker = new Docker(); 12 | 13 | docker.createContainer( 14 | { 15 | Image: image, 16 | Cmd: ["-S", `DocumentDirectory=${modelFolder}`, "-S", "AcceptEULA=yes"], 17 | ExposedPorts: { 18 | "9076/tcp": {} 19 | }, 20 | HostConfig: { 21 | RestartPolicy: { 22 | Name: "always" 23 | }, 24 | Binds: [`${path.join(process.cwd(), modelFolder)}:${modelFolder}`], // ["./models:/models"], 25 | PortBindings: { 26 | "9076/tcp": [ 27 | { 28 | HostPort: port 29 | } 30 | ] 31 | } 32 | } 33 | }, 34 | (err, container) => { 35 | if (err) return observer.error(err); 36 | 37 | container.start((err, data) => { 38 | if (err) return observer.error(err); 39 | setTimeout(() => { 40 | observer.next(container); 41 | observer.complete(); 42 | }, 2000); 43 | }); 44 | } 45 | ); 46 | }).pipe( 47 | publishReplay(1), 48 | refCount() 49 | ); 50 | 51 | return container$; 52 | } 53 | 54 | module.exports = createContainer; 55 | -------------------------------------------------------------------------------- /docs/recipes/current-selections.md: -------------------------------------------------------------------------------- 1 | # Current Selections 2 | [Code Sandbox](https://codesandbox.io/embed/8xk40y9588) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject, GetField } from "rxq/Doc"; 7 | import { GetLayout } from "rxq/GenericObject"; 8 | import { shareReplay, startWith, switchMap } from "rxjs/operators"; 9 | 10 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 11 | 12 | // Define the configuration for your session 13 | const config = { 14 | host: "sense.axisgroup.com", 15 | isSecure: true, 16 | appname 17 | }; 18 | 19 | // Connect the session and share the Global handle 20 | const session = connectSession(config); 21 | const global$ = session.global$; 22 | 23 | // Open an app and share the app handle 24 | const app$ = global$.pipe( 25 | switchMap(h => h.ask(OpenDoc, appname)), 26 | shareReplay(1) 27 | ); 28 | 29 | // Create a Generic Object with the current selections 30 | const obj$ = app$.pipe( 31 | switchMap(h => h.ask(CreateSessionObject, { 32 | "qInfo": { 33 | "qType": "my-object" 34 | }, 35 | "qSelectionObjectDef": {} 36 | })), 37 | shareReplay(1) 38 | ); 39 | 40 | // Get the latest selections whenever the model changes 41 | const selections$ = obj$.pipe( 42 | switchMap(h => h.invalidated$.pipe(startWith(h))), 43 | switchMap(h => h.ask(GetLayout)) 44 | ); 45 | 46 | // Print the selections to the DOM 47 | selections$.subscribe(layout => { 48 | document.querySelector("#content").innerHTML = layout.qSelectionObject.qSelections.map(sel => `${sel.qField}: ${sel.qSelected}`).join("") 49 | }); 50 | ``` -------------------------------------------------------------------------------- /docs/introduction/rxq-vs-enigma.md: -------------------------------------------------------------------------------- 1 | # RxQ vs. enigma.js 2 | [enigma.js](https://github.com/qlik-oss/enigma.js) is a fantastic library by the Qlik R&D team that has a similar goal to RxQ: it provides a means for working with the Qlik Associative Engine (QAE) in a scalable way. RxQ was largely inspired by the work that was done on enigma.js. 3 | 4 | enigma.js and RxQ take fundamentally different approaches to how they deal with the asynchronous nature of QAE. enigma.js is built around the Promise model. In JavaScript, Promises are objects that model an asynchronous operation that eventually completes with a single value, or fails. Promises have become a standard async method in JavaScript development. They work great for one time asynchronous events, can be chained, and are fairly easy to learn and put to work. Therefore, they are a solid choice for getting started with QAE. 5 | 6 | RxQ does not use Promises in its implementation. Since it is meant to enable reactive programming with QAE, it instead uses the [Observable](https://egghead.io/lessons/javascript-introducing-the-observable) pattern. Observables are the core component of RxJS and provide the benefits described in [Why Rx?](why-rx.md). However, Observables are a newer concept to the JavaScript world and have not been as widely adopted. Furthermore, the learning curve is much steeper than Promises. 7 | 8 | When comparing enigma.js to RxQ, our feeling is that enigma.js has a much lower learning curve and works exceptionally well for basic use cases. While RxQ has a higher learning curve due to the usage of Rx, we believe that those who use Rx via RxQ will find that it shines as you scale up to more advanced applications of QAE. -------------------------------------------------------------------------------- /src/util/qix-connect-string.js: -------------------------------------------------------------------------------- 1 | export default function(config) { 2 | var cfg = {}; 3 | // Copy properties + defaults 4 | if (config) { 5 | cfg.port = config.port; 6 | cfg.appname = config.appname || false; 7 | cfg.host = config.host; 8 | cfg.prefix = config.prefix || false; 9 | cfg.isSecure = config.isSecure; 10 | cfg.identity = config.identity; 11 | cfg.ticket = config.ticket; 12 | } 13 | 14 | return ConnectionString(cfg); 15 | }; 16 | 17 | function ConnectionString(config) { 18 | 19 | // Define host 20 | var host = (config && config.host) ? config.host : 'localhost'; 21 | // Define port 22 | var port; 23 | 24 | // Configure port if port is undefined 25 | if (config && config.port === undefined) { 26 | port = ''; 27 | } else { 28 | port = (config && config.port) ? ':' + config.port : ''; 29 | }; 30 | 31 | // Define secure vs. unsecure 32 | var isSecure = (config && config.isSecure) ? 'wss://' : 'ws://'; 33 | 34 | // Prefix 35 | var prefix = config.prefix ? config.prefix : '/'; 36 | 37 | if (prefix.slice(0, 1) !== '/') { 38 | prefix = '/' + prefix; 39 | }; 40 | if (prefix.split('').pop() !== '/') { 41 | prefix = prefix + '/'; 42 | }; 43 | 44 | var suffix = config.appname ? 'app/' + config.appname + '/' : 'app/'; 45 | var identity = (config && config.identity) ? 'identity/' + config.identity : ''; 46 | var ticket = config.ticket ? '?qlikTicket=' + config.ticket : ''; 47 | 48 | var url = isSecure + host + port + prefix + suffix + identity + ticket; 49 | 50 | return url; 51 | 52 | } -------------------------------------------------------------------------------- /docs/basics/suspending-invalidations.md: -------------------------------------------------------------------------------- 1 | # Suspending Invalidations 2 | The previous section detailed how to hook into Handle invalidation events to create automatic updating streams from Handles. It is common to scale this pattern up when building an interactive dashboard; the dashboard may have several charts on it, all of which are hooking into various layout streams that are auto-updating from invalidation events. 3 | 4 | This pattern works great when simple operations like applying a selection cause invalidation. However, sometimes we want to make several API calls that alter the app before we update everything. This can cause havoc with our auto-updating layouts: they may try to update after every API call, rather than waiting for all the calls to be finished first. RxQ can handle this scenario in two ways. 5 | 6 | ## Manually changing suspense status 7 | The `session` object returned by RxQ contains methods `session.suspend()` and `session.unsuspend()` that can be called to manually change the suspense status of the session. 8 | 9 | ## Using the `suspendUntilCompleted` operator 10 | RxQ provides an operator called `suspendUntilCompleted` that can handle the suspense side-effects for you. This operator can be applied to an Observable that completes. It takes a session as an input. When the resulting Observable is subscribed to, it will pause the invalidation event streams in the session, and then execute the Observable stream. When the Observable completes, it will unpause the session and pass down any invalidation events that occured during the paused period. 11 | 12 | An example of this can be seen in the [Batch Invalidations with Suspend](../recipes/batch-invalidations.html) example. -------------------------------------------------------------------------------- /docs/recipes/set-app-props.md: -------------------------------------------------------------------------------- 1 | # Set App Properties and Listen for Changes 2 | [Code Sandbox](https://codesandbox.io/embed/n4v0p3m4n4) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { GetAppProperties, SetAppProperties } from "rxq/Doc"; 7 | import { publish, shareReplay, startWith, 8 | switchMap, withLatestFrom } from "rxjs/operators"; 9 | import { fromEvent } from "rxjs"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 12 | 13 | // Define the configuration for your session 14 | const config = { 15 | host: "sense.axisgroup.com", 16 | isSecure: true, 17 | appname 18 | }; 19 | 20 | // Connect the session and share the Global handle 21 | const session = connectSession(config); 22 | const global$ = session.global$; 23 | 24 | // Open an app and share the app handle 25 | const app$ = global$.pipe( 26 | switchMap(h => h.ask(OpenDoc, appname)), 27 | shareReplay(1) 28 | ); 29 | 30 | // Get the app properties any time the app invalidates 31 | const appProps$ = app$.pipe( 32 | switchMap(h => h.invalidated$.pipe(startWith(h))), 33 | switchMap(h => h.ask(GetAppProperties)) 34 | ); 35 | 36 | // Write the app title and modified date to the DOM 37 | appProps$.subscribe(props => { 38 | document.querySelector("#content").innerHTML = `The app's prop "random" is ${props.random}`; 39 | }); 40 | 41 | // Whenever a user clicks the button, update the app props with a random prop 42 | const updateAppProps$ = fromEvent(document.querySelector("#set-random"), "click").pipe( 43 | withLatestFrom(app$), 44 | switchMap(([evt, h]) => h.ask(SetAppProperties, { 45 | "random": Math.random() 46 | })), 47 | publish() 48 | ); 49 | 50 | // Connect the app update mechanism 51 | updateAppProps$.connect(); 52 | ``` -------------------------------------------------------------------------------- /scripts/compile_esm2015.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var fs = require("fs"); 3 | var rimraf = require("rimraf"); 4 | var babel = require("babel-core"); 5 | 6 | // Set up dist 7 | mkDir("dist"); 8 | // Remove _esm5 if it exists 9 | rimraf.sync("dist/_esm2015", {}, function() {}); 10 | // Set up _esm2015 11 | mkDir("dist/_esm2015"); 12 | 13 | // Iterate through source code and copy over 14 | var srcFolder = "src"; 15 | var tgtFolder = "dist/_esm2015"; 16 | 17 | compileFromDir(srcFolder, tgtFolder); 18 | 19 | function compileFromDir(srcFolder, tgtFolder) { 20 | fs.readdir(srcFolder, function(err, files) { 21 | files.forEach(function(file) { 22 | fs.lstat(path.join(srcFolder, file), function(err, stats) { 23 | if (stats.isFile() && file.slice(-3) === ".js") { 24 | //console.log("convert the file " + file); 25 | babel.transformFile( 26 | path.join(srcFolder, file), 27 | { 28 | presets: [], 29 | plugins: ["transform-object-rest-spread", "transform-runtime"] 30 | }, 31 | function(err, result) { 32 | //if(err) return console.log(err); 33 | fs.writeFile(path.join(tgtFolder, file), result.code, function( 34 | err 35 | ) { 36 | if (err) return console.log(err); 37 | }); 38 | } 39 | ); 40 | } else if (stats.isDirectory()) { 41 | mkDir(path.join(tgtFolder, file)); 42 | compileFromDir( 43 | path.join(srcFolder, file), 44 | path.join(tgtFolder, file) 45 | ); 46 | } 47 | }); 48 | }); 49 | }); 50 | } 51 | 52 | function mkDir(dir) { 53 | if (!fs.existsSync(dir)) { 54 | fs.mkdirSync(dir); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /scripts/compile_esm5.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var fs = require("fs"); 3 | var rimraf = require("rimraf"); 4 | var babel = require("babel-core"); 5 | 6 | // Set up dist 7 | mkDir("dist"); 8 | // Remove _esm5 if it exists 9 | rimraf.sync("dist/_esm5", {}, function() {}); 10 | // Set up _esm5 11 | mkDir("dist/_esm5"); 12 | 13 | // Iterate through source code and copy over 14 | var srcFolder = "src"; 15 | var tgtFolder = "dist/_esm5"; 16 | 17 | compileFromDir(srcFolder, tgtFolder); 18 | 19 | function compileFromDir(srcFolder, tgtFolder) { 20 | fs.readdir(srcFolder, function(err, files) { 21 | files.forEach(function(file) { 22 | fs.lstat(path.join(srcFolder, file), function(err, stats) { 23 | if (stats.isFile() && file.slice(-3) === ".js") { 24 | //console.log("convert the file " + file); 25 | babel.transformFile( 26 | path.join(srcFolder, file), 27 | { 28 | presets: [["env", { modules: false }]], 29 | plugins: ["transform-object-rest-spread", "transform-runtime"] 30 | }, 31 | function(err, result) { 32 | //if(err) return console.log(err); 33 | fs.writeFile(path.join(tgtFolder, file), result.code, function( 34 | err 35 | ) { 36 | if (err) return console.log(err); 37 | }); 38 | } 39 | ); 40 | } else if (stats.isDirectory()) { 41 | mkDir(path.join(tgtFolder, file)); 42 | compileFromDir( 43 | path.join(srcFolder, file), 44 | path.join(tgtFolder, file) 45 | ); 46 | } 47 | }); 48 | }); 49 | }); 50 | } 51 | 52 | function mkDir(dir) { 53 | if (!fs.existsSync(dir)) { 54 | fs.mkdirSync(dir); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /scripts/compile_cjs.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var fs = require("fs"); 3 | var rimraf = require("rimraf"); 4 | var babel = require("babel-core"); 5 | 6 | // Set up dist 7 | mkDir("dist"); 8 | 9 | // Remove _cjs if it exists 10 | rimraf.sync("dist/_cjs", {}, function() {}); 11 | // Set up _cjs 12 | mkDir("dist/_cjs"); 13 | 14 | // Iterate through source code and copy over 15 | var srcFolder = "src"; 16 | var tgtFolder = "dist/_cjs"; 17 | 18 | compileFromDir(srcFolder, tgtFolder); 19 | 20 | function compileFromDir(srcFolder, tgtFolder) { 21 | fs.readdir(srcFolder, function(err, files) { 22 | files.forEach(function(file) { 23 | fs.lstat(path.join(srcFolder, file), function(err, stats) { 24 | if (stats.isFile() && file.match(/.js$/g) !== null) { 25 | babel.transformFile( 26 | path.join(srcFolder, file), 27 | { 28 | presets: ["es2015"], 29 | plugins: [ 30 | "transform-object-rest-spread", 31 | "add-module-exports", 32 | "transform-es2015-modules-commonjs", 33 | "transform-runtime" 34 | ] 35 | }, 36 | function(err, result) { 37 | if (err) return console.log(err); 38 | fs.writeFile(path.join(tgtFolder, file), result.code, function( 39 | err 40 | ) { 41 | if (err) return console.log(err); 42 | }); 43 | } 44 | ); 45 | } else if (stats.isDirectory()) { 46 | mkDir(path.join(tgtFolder, file)); 47 | compileFromDir( 48 | path.join(srcFolder, file), 49 | path.join(tgtFolder, file) 50 | ); 51 | } 52 | }); 53 | }); 54 | }); 55 | } 56 | 57 | function mkDir(dir) { 58 | if (!fs.existsSync(dir)) { 59 | fs.mkdirSync(dir); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /scripts/make-packages.js: -------------------------------------------------------------------------------- 1 | var pkg = require("../package.json"); 2 | var fs = require("fs"); 3 | var path = require("path"); 4 | 5 | // Copy package to dist folder 6 | const modPkg = { 7 | ...pkg, 8 | main: "./_cjs/index.js", 9 | module: "./_esm5/index.js", 10 | es2015: "./_esm2015/index.js", 11 | sideEffects: false 12 | }; 13 | 14 | fs.writeFile( 15 | path.join(__dirname, "../dist/package.json"), 16 | JSON.stringify(modPkg, null, "\t"), 17 | function(err) { 18 | if (err) console.log(err); 19 | } 20 | ); 21 | 22 | // Qlik Class Entrypoints 23 | const classes = [ 24 | "Doc", 25 | "Field", 26 | "GenericBookmark", 27 | "GenericDerivedFields", 28 | "GenericDimension", 29 | "GenericMeasure", 30 | "GenericObject", 31 | "GenericVariable", 32 | "Global", 33 | "Variable" 34 | ]; 35 | classes.forEach(qClass => { 36 | var operatorFolder = path.join(__dirname, "../src", qClass); 37 | var operatorOutputFolder = path.join(__dirname, "../dist", qClass); 38 | mkDir(operatorOutputFolder); 39 | 40 | fs.readdir(operatorFolder, (err, files) => { 41 | if (err) return console.log(err); 42 | createPackage("index.js", qClass, operatorOutputFolder, 1); 43 | }); 44 | }); 45 | 46 | function createPackage(filename, folderPath, outputFolder, depth) { 47 | var trail = "../".repeat(depth); 48 | 49 | var pkg = { 50 | main: `${trail}_cjs/${folderPath}/${filename}`, 51 | module: `${trail}_esm5/${folderPath}/${filename}`, 52 | es2015: `${trail}_esm2015/${folderPath}/${filename}`, 53 | sideEffects: false 54 | }; 55 | 56 | mkDir(outputFolder); 57 | 58 | fs.writeFile( 59 | path.join(outputFolder, "package.json"), 60 | JSON.stringify(pkg, null, "\t"), 61 | function(err) { 62 | if (err) console.log(err); 63 | } 64 | ); 65 | } 66 | 67 | function mkDir(dir) { 68 | if (!fs.existsSync(dir)) { 69 | fs.mkdirSync(dir); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docs/basics/connecting.md: -------------------------------------------------------------------------------- 1 | # Connecting 2 | In order to work with QAE, we need to establish a session with the Engine. This session is created by connecting to an Engine over a WebSocket. 3 | 4 | RxQ provides a function called `connectSession` that will establish the session with the Engine. It returns a Session object that contains an Observable for the Global Handle, a `close` function for closing the session imperatively, and an Observable of notifications from the engine and RxQ (useful for debugging): 5 | 6 | Session Object Properties 7 | * **global$** - *(Observable)* An Observable for the Global Handle of the session 8 | * **close** - *(Function)* A function that closes the WebSocket, ending the session 9 | * **notifications$** - *(Observable) An Observable of notifications from the engine and RxQ 10 | 11 | ## Setup 12 | The `connectSession` function takes in a configuration object that determines the session that is created. It can read the following properties: 13 | 14 | * **host** - *(String)* Hostname of server 15 | * **appname** - *(String)* Scoped connection to app. 16 | * **isSecure** - *(Boolean)* If true uses wss and port 443, otherwise ws and port 80 17 | * **port** - *(Integer)* Port of connection, defaults 443/80 18 | * **prefix** - *(String)* Virtual Proxy, defaults to '/' 19 | * **origin** - *(String)* Origin of requests, node only. 20 | * **rejectUnauthorized** - *(Boolean)* False will ignore unauthorized self-signed certs. 21 | * **headers** - *(Object)* HTTP headers 22 | * **ticket** - *(String)* Qlik Sense ticket, consumes ticket on Connect() 23 | * **key** - *(String)* Client Certificate key for QIX connections 24 | * **cert** - *(String)* Client certificate for QIX connections 25 | * **ca** - *(Array of String)* CA root certificates for QIX connections 26 | * **identity** - *(String)* Session identity 27 | 28 | ## Usage 29 | The following example shows how to use RxQ to connect to an Engine behind Qlik Sense Desktop: 30 | ```javascript 31 | import { connectSession } from "rxq"; 32 | 33 | const session = connectSession({ 34 | host: "localhost", 35 | port: 4848, 36 | isSecure: false 37 | }); 38 | 39 | session.global$.subscribe((globalHandle) => { 40 | // Do something with the Global Handle 41 | }); 42 | ``` -------------------------------------------------------------------------------- /docs/recipes/get-data-page-on-invalid.md: -------------------------------------------------------------------------------- 1 | # Get a page of data from a HyperCube on invalidation 2 | [Code Sandbox](https://codesandbox.io/embed/j796z127n3) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout, GetHyperCubeData } from "rxq/GenericObject"; 8 | import { shareReplay, startWith, switchMap } from "rxjs/operators"; 9 | 10 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 11 | 12 | // Define the configuration for your session 13 | const config = { 14 | host: "sense.axisgroup.com", 15 | isSecure: true, 16 | appname 17 | }; 18 | 19 | // Connect the session and share the Global handle 20 | const session = connectSession(config); 21 | const global$ = session.global$; 22 | 23 | // Open an app and share the app handle 24 | const app$ = global$.pipe( 25 | switchMap(h => h.ask(OpenDoc, appname)), 26 | shareReplay(1) 27 | ); 28 | 29 | // Create a Generic Object with a metric 30 | const obj$ = app$.pipe( 31 | switchMap(h => h.ask(CreateSessionObject, { 32 | "qInfo": { 33 | "qType": "my-object" 34 | }, 35 | "qHyperCubeDef": { 36 | "qDimensions": [ 37 | { 38 | "qDef": { 39 | "qFieldDefs": ["petal_length"] 40 | } 41 | } 42 | ], 43 | "qMeasures": [ 44 | { 45 | "qDef": { 46 | "qDef": "=avg(petal_width)" 47 | } 48 | } 49 | ] 50 | } 51 | })), 52 | shareReplay(1) 53 | ); 54 | 55 | // On invalidation, get layout to validate, then request a data page 56 | const data$ = obj$.pipe( 57 | switchMap(h => h.invalidated$.pipe(startWith(h))), 58 | switchMap(h => h.ask(GetLayout), (h, layout) => h), 59 | switchMap(h => h.ask(GetHyperCubeData, "/qHyperCubeDef", [ 60 | { 61 | qTop: 0, 62 | qLeft: 0, 63 | qWidth: 2, 64 | qHeight: 100 65 | } 66 | ])) 67 | ); 68 | 69 | data$.subscribe(pages => { 70 | const data = pages[0].qMatrix; 71 | document.querySelector("tbody").innerHTML = data.map(row => { 72 | return ` 73 | ${row[0].qText} 74 | ${row[1].qText} 75 | ` 76 | }).join(""); 77 | }); 78 | 79 | ``` -------------------------------------------------------------------------------- /docs/recipes/batch-invalidations.md: -------------------------------------------------------------------------------- 1 | # Batch Invalidations 2 | [Code Sandbox](https://codesandbox.io/embed/7z64r623r6) 3 | ```javascript 4 | import { connectSession, suspendUntilCompleted } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { ClearAll, CreateSessionObject, GetField } from "rxq/Doc"; 7 | import { GetLayout } from "rxq/GenericObject"; 8 | import { LowLevelSelect } from "rxq/Field"; 9 | import { fromEvent, concat } from "rxjs"; 10 | import { publish, shareReplay, startWith, switchMap, take } from "rxjs/operators"; 11 | 12 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 13 | 14 | // Define the configuration for your session 15 | const config = { 16 | host: "sense.axisgroup.com", 17 | isSecure: true, 18 | appname 19 | }; 20 | 21 | // Connect the session and share the Global handle 22 | const session = connectSession(config); 23 | const global$ = session.global$; 24 | 25 | // Open app in session 26 | const app$ = global$.pipe( 27 | switchMap(h => h.ask(OpenDoc, appname)), 28 | shareReplay(1) 29 | ); 30 | 31 | // Get a stream of layouts 32 | const layout$ = app$.pipe( 33 | switchMap(h => h.ask(CreateSessionObject, { 34 | "qInfo": { 35 | "qType": "custom" 36 | }, 37 | "value": { 38 | "qValueExpression": "=avg(petal_width)" 39 | } 40 | })), 41 | switchMap(h => h.invalidated$.pipe(startWith(h))), 42 | switchMap(h => h.ask(GetLayout)), 43 | shareReplay(1) 44 | ); 45 | 46 | // Log latest layout 47 | layout$.subscribe(layout => { 48 | document.querySelector("#metric").innerHTML = layout.value; 49 | }); 50 | 51 | // Clear operation 52 | const clearAll$ = app$.pipe( 53 | switchMap(h => h.ask(ClearAll)), 54 | take(1) 55 | ); 56 | 57 | // Filter a field operation 58 | const filterFld$ = app$.pipe( 59 | switchMap(h => h.ask(GetField, "species")), 60 | switchMap(h => h.ask(LowLevelSelect, [0], false)), 61 | take(1) 62 | ); 63 | 64 | // Create batched operations sequence with suspension 65 | const batchedOps$ = concat(clearAll$, filterFld$).pipe( 66 | suspendUntilCompleted(session) 67 | ); 68 | 69 | // Click stream to trigger the batched operations 70 | const runOps$ = fromEvent(document.querySelector("button"), "click").pipe( 71 | switchMap(() => batchedOps$), 72 | publish() 73 | ); 74 | 75 | runOps$.connect(); 76 | ``` -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | * [RxQ v2.0.1](README.md) 3 | * [Introduction]() 4 | * [Why Rx?](/introduction/why-rx.md) 5 | * [RxQ vs. Enigma](introduction/rxq-vs-enigma.md) 6 | * [Core Concepts](introduction/core-concepts.md) 7 | * [Basics]() 8 | * [Connecting](basics/connecting.md) 9 | * [Making API Calls](basics/making-api-calls.md) 10 | * [Reusing Handles](basics/reusing-handles.md) 11 | * [Handle Invalidations](basics/handle-invalidations.md) 12 | * [Suspending Invalidations](basics/suspending-invalidations.md) 13 | * [Closing a Session](basics/closing-a-session.md) 14 | * [Notifications](basics/notifications.md) 15 | * [Delta Mode](basics/delta.md) 16 | * [Shortcut Operators](basics/shortcut-operators.md) 17 | * [Recipes]() 18 | * [01. Connect to an Engine](/recipes/connect.md) 19 | * [02. Get the Engine Version](/recipes/engine-version.md) 20 | * [03. Make Multiple Global Calls](/recipes/multiple-global-calls.md) 21 | * [04. Open an App](/recipes/open-an-app.md) 22 | * [05. Get App Properties](/recipes/read-app-props.md) 23 | * [06. Set App Properties and Listen for Changes](/recipes/set-app-props.md) 24 | * [07. Calculate a Value with a GenericObject](/recipes/gen-obj-value.md) 25 | * [08. Get a stream of Current Selections](/recipes/current-selections.md) 26 | * [09. Make a Selection in a Field](/recipes/make-a-selection.md) 27 | * [10. Loop through Selections in a Field](/recipes/loop-selections.md) 28 | * [11. Make Selections in a Listbox](/recipes/make-lb-selections.md) 29 | * [12. Search in a Listbox](/recipes/search-lb.md) 30 | * [13. Apply Patches to an Object](/recipes/apply-patches.md) 31 | * [14. Get a page of data in a HyperCube on invalidation](/recipes/get-data-page-on-invalid.md) 32 | * [15. Get pages of data in parallel](/recipes/get-pages-in-parallel.md) 33 | * [16. Get pages of data sequentially](/recipes/get-pages-in-sequence.md) 34 | * [17. Make selections on app open](/recipes/make-selections-on-app-open.md) 35 | * [18. Calculate the duration of an API call and response](/recipes/calc-time.md) 36 | * [19. Batch Invalidations with Suspend](/recipes/batch-invalidations.md) 37 | * [20. Toggle Calculations Between Multiple Apps](/recipes/toggle-sessions.md) 38 | * [21. Auto-reconnect Generic Objects after a network drop](/recipes/offline.md) 39 | * [22. Using shortcut operators to get a layout stream](/recipes/shortcut-operators.md) -------------------------------------------------------------------------------- /test/unit/connectSession.spec.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var { Observable, Subject } = require("rxjs"); 6 | var { pluck, take } = require("rxjs/operators"); 7 | const mockEngine = require("../util/mock-qix-engine.js"); 8 | const isObservable = require("../util/isObservable"); 9 | 10 | // RxQ 11 | var { connectSession } = require("../../dist"); 12 | var Handle = require("../../dist/_cjs/handle"); 13 | var Session = require("../../dist/_cjs/session"); 14 | 15 | describe("connectSession", function() { 16 | // Mock Engine for Testing 17 | var { server, ws } = mockEngine(); 18 | var config = { 19 | ws 20 | }; 21 | 22 | const session = connectSession(config); 23 | var eng$ = session.global$; 24 | 25 | it("should be a function", function() { 26 | expect(connectSession).to.be.a("function"); 27 | }); 28 | 29 | it("should return an object", function() { 30 | expect(session).to.be.an("object"); 31 | }); 32 | 33 | describe("notifications$", function() { 34 | const notifications$ = session.notifications$; 35 | 36 | it("should be an Observable", function() { 37 | expect(isObservable(notifications$)).to.equal(true); 38 | }); 39 | }); 40 | 41 | describe("close", function() { 42 | const close = session.close; 43 | 44 | it("should be a function", function() { 45 | expect(close).to.be.a("function"); 46 | }); 47 | }); 48 | 49 | describe("suspend", function() { 50 | const suspend = session.suspend; 51 | 52 | it("should be a function", function() { 53 | expect(suspend).to.be.a("function"); 54 | }); 55 | }); 56 | 57 | describe("unsuspend", function() { 58 | const unsuspend = session.unsuspend; 59 | 60 | it("should be a function", function() { 61 | expect(unsuspend).to.be.a("function"); 62 | }); 63 | }); 64 | 65 | describe("global$", function() { 66 | it("should be an Observable", function() { 67 | expect(isObservable(eng$)).to.equal(true); 68 | }); 69 | 70 | it("should return a Handle", function(done) { 71 | eng$.subscribe(h => { 72 | expect(h).to.be.instanceof(Handle); 73 | done(); 74 | }); 75 | }); 76 | 77 | it("the Handle should be -1", function(done) { 78 | eng$.subscribe(h => { 79 | expect(h.handle).to.equal(-1); 80 | done(); 81 | }); 82 | }); 83 | }); 84 | 85 | after(function() { 86 | server.stop(); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /docs/recipes/make-a-selection.md: -------------------------------------------------------------------------------- 1 | # Make a Selection 2 | [Code Sandbox](https://codesandbox.io/embed/6xq8m44lnr) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject, GetField } from "rxq/Doc"; 7 | import { GetLayout } from "rxq/GenericObject"; 8 | import { GetCardinal, Select } from "rxq/Field"; 9 | import { mapTo, publish, shareReplay, startWith, switchMap, withLatestFrom } from "rxjs/operators"; 10 | import { fromEvent, merge } from "rxjs"; 11 | 12 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 13 | 14 | // Define the configuration for your session 15 | const config = { 16 | host: "sense.axisgroup.com", 17 | isSecure: true, 18 | appname 19 | }; 20 | 21 | // Connect the session and share the Global handle 22 | const session = connectSession(config); 23 | const global$ = session.global$; 24 | 25 | // Open an app and share the app handle 26 | const app$ = global$.pipe( 27 | switchMap(h => h.ask(OpenDoc, appname)), 28 | shareReplay(1) 29 | ); 30 | 31 | // Create a Generic Object with the current selections 32 | const obj$ = app$.pipe( 33 | switchMap(h => h.ask(CreateSessionObject, { 34 | "qInfo": { 35 | "qType": "my-object" 36 | }, 37 | "qSelectionObjectDef": {} 38 | })), 39 | shareReplay(1) 40 | ); 41 | 42 | // Get the latest selections whenever the model changes 43 | const selections$ = obj$.pipe( 44 | switchMap(h => h.invalidated$.pipe(startWith(h))), 45 | switchMap(h => h.ask(GetLayout)) 46 | ); 47 | 48 | // Print the selections to the DOM 49 | selections$.subscribe(layout => { 50 | document.querySelector("#content").innerHTML = layout.qSelectionObject.qSelections.map(sel => `${sel.qField}: ${sel.qSelected}`).join("") 51 | }); 52 | 53 | // Get a field 54 | const fld$ = app$.pipe( 55 | switchMap(h => h.ask(GetField, "species")), 56 | shareReplay(1) 57 | ); 58 | 59 | // On click, emit the value "setosa" 60 | const selectSetosa$ = fromEvent(document.querySelector("#filter"), "click").pipe( 61 | mapTo("setosa") 62 | ); 63 | 64 | // On click, emit an empty string 65 | const clearSelect$ = fromEvent(document.querySelector("#unfilter"), "click").pipe( 66 | mapTo("") 67 | ); 68 | 69 | // Create a stream of select actions to the field from the button clicks 70 | const selectValue$ = merge(selectSetosa$, clearSelect$).pipe( 71 | withLatestFrom(fld$), 72 | switchMap(([sel, h]) => h.ask(Select, sel)), 73 | publish() 74 | ); 75 | 76 | // Connect the selection stream 77 | selectValue$.connect(); 78 | ``` -------------------------------------------------------------------------------- /test/e2e/field.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | publish, 8 | publishReplay, 9 | refCount, 10 | shareReplay, 11 | switchMap, 12 | take, 13 | tap, 14 | withLatestFrom 15 | } = require("rxjs/operators"); 16 | 17 | var { OpenDoc } = require("../../dist/global"); 18 | var { connectSession } = require("../../dist"); 19 | var Handle = require("../../dist/_cjs/handle"); 20 | 21 | var { GetField } = require("../../dist/doc"); 22 | var { GetCardinal, GetNxProperties } = require("../../dist/field"); 23 | 24 | var { port, image } = require("./config.json"); 25 | 26 | // launch a new container 27 | var container$ = createContainer(image, port); 28 | 29 | var eng$ = container$.pipe( 30 | switchMap(() => { 31 | return connectSession({ 32 | host: "localhost", 33 | port: port, 34 | isSecure: false 35 | }).global$; 36 | }), 37 | publishReplay(1), 38 | refCount() 39 | ); 40 | 41 | const app$ = eng$.pipe( 42 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 43 | publishReplay(1), 44 | refCount() 45 | ); 46 | 47 | const field$ = app$.pipe( 48 | switchMap(handle => handle.ask(GetField, "species")), 49 | shareReplay(1) 50 | ); 51 | 52 | function testField() { 53 | describe("Field Class", function() { 54 | before(function(done) { 55 | this.timeout(10000); 56 | container$.subscribe(() => done()); 57 | }); 58 | 59 | describe("GetNxProperties", function() { 60 | const fldProps$ = field$.pipe( 61 | switchMap(h => h.ask(GetNxProperties)), 62 | shareReplay(1) 63 | ); 64 | 65 | it("should return an object", function(done) { 66 | fldProps$.subscribe(props => { 67 | expect(props).to.be.a("object"); 68 | done(); 69 | }); 70 | }); 71 | }); 72 | 73 | describe("GetCardinal", function() { 74 | const fldCard$ = field$.pipe( 75 | switchMap(h => h.ask(GetCardinal)), 76 | shareReplay(1) 77 | ); 78 | 79 | it("should equal 3 for the field 'species'", function(done) { 80 | fldCard$.subscribe(card => { 81 | expect(card).to.equal(3); 82 | done(); 83 | }); 84 | }); 85 | }); 86 | 87 | after(function(done) { 88 | container$.subscribe(container => 89 | container.kill((err, result) => { 90 | container.remove(); 91 | done(); 92 | }) 93 | ); 94 | }); 95 | }); 96 | } 97 | 98 | module.exports = testField; 99 | -------------------------------------------------------------------------------- /docs/recipes/toggle-sessions.md: -------------------------------------------------------------------------------- 1 | # Toggle Sessions 2 | [Code Sandbox](https://codesandbox.io/embed/nnr22k0nx4) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { GetActiveDoc, OpenDoc } from "rxq/Global"; 6 | import { GetAppProperties, GetTablesAndKeys } from "rxq/Doc"; 7 | import { fromEvent, forkJoin, combineLatest } from "rxjs"; 8 | import { map, pluck, shareReplay, startWith, switchMap, withLatestFrom } from "rxjs/operators"; 9 | 10 | // Define the configuration for your session 11 | const configs = [ 12 | { 13 | host: "sense.axisgroup.com", 14 | isSecure: true, 15 | appname: "aae16724-dfd9-478b-b401-0d8038793adf" 16 | }, 17 | { 18 | host: "sense.axisgroup.com", 19 | isSecure: true, 20 | appname: "3a64c6ff-94b4-43e3-b993-4040cf889c64" 21 | }, 22 | ]; 23 | 24 | // Create a stream for the current session being viewed 25 | const sessionNo$ = fromEvent(document.querySelector("select"), "change").pipe( 26 | map(evt => parseInt(evt.target.value)), 27 | startWith(0) 28 | ); 29 | 30 | // Create an array of sessions 31 | const sessions$ = forkJoin( 32 | configs 33 | .map(config => connectSession(config).global$ 34 | .pipe(shareReplay(1)) 35 | ) 36 | ); 37 | 38 | // The current session 39 | const sesh$ = combineLatest(sessions$, sessionNo$).pipe( 40 | map(([engines, no]) => engines[no]), 41 | shareReplay(1) 42 | ); 43 | 44 | // When switching sessions, get the doc 45 | const app$ = sesh$.pipe( 46 | withLatestFrom(sessionNo$), 47 | switchMap(([h, no]) => h.ask(OpenDoc, configs[no].appname)), 48 | shareReplay(1) 49 | ); 50 | 51 | // Get the current app title 52 | const appTitle$ = app$.pipe( 53 | switchMap(h => h.ask(GetAppProperties)), 54 | pluck("qTitle") 55 | ); 56 | 57 | // Print the app title to the DOM 58 | appTitle$.subscribe(title => { 59 | document.querySelector("#app-title").innerHTML = title; 60 | }); 61 | 62 | // Get the fields in the current app 63 | const fieldList$ = app$.pipe( 64 | switchMap(h => h.ask(GetTablesAndKeys, { "qcx": 1000, "qcy": 1000 }, { "qcx": 0, "qcy": 0 }, 30, true, false)) 65 | ); 66 | 67 | // List the fields in the DOM 68 | fieldList$.subscribe(response => { 69 | const tables = response.qtr; 70 | const flds = tables.map(table => { 71 | const tableflds = table.qFields; 72 | return tableflds.map(fld => ({ 73 | table: table.qName, 74 | field: fld.qName 75 | })); 76 | }) 77 | .reduce((acc, curr) => { 78 | return acc.concat(curr); 79 | }, []); 80 | 81 | document.querySelector("tbody").innerHTML = flds.map(fld => `${fld.table}${fld.field}`).join(""); 82 | }); 83 | ``` -------------------------------------------------------------------------------- /docs/recipes/loop-selections.md: -------------------------------------------------------------------------------- 1 | # Loop Through Selections in a Field 2 | [Code Sandbox](https://codesandbox.io/embed/nwwrm598r0) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject, GetField } from "rxq/Doc"; 7 | import { GetLayout } from "rxq/GenericObject"; 8 | import { GetCardinal, LowLevelSelect } from "rxq/Field"; 9 | import { map, publish, repeat, shareReplay, startWith, switchMap, take, withLatestFrom } from "rxjs/operators"; 10 | import { interval } from "rxjs"; 11 | 12 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 13 | 14 | // Define the configuration for your session 15 | const config = { 16 | host: "sense.axisgroup.com", 17 | isSecure: true, 18 | appname 19 | }; 20 | 21 | // Connect the session and share the Global handle 22 | const session = connectSession(config); 23 | const global$ = session.global$; 24 | 25 | 26 | // Open an app and share the app handle 27 | const app$ = global$.pipe( 28 | switchMap(h => h.ask(OpenDoc, appname)), 29 | shareReplay(1) 30 | ); 31 | 32 | // Create a Generic Object with the current selections and a metric 33 | const obj$ = app$.pipe( 34 | switchMap(h => h.ask(CreateSessionObject, { 35 | "qInfo": { 36 | "qType": "my-object" 37 | }, 38 | "qSelectionObjectDef": {}, 39 | "myValue": { 40 | "qValueExpression": "=avg(petal_length)" 41 | } 42 | })), 43 | shareReplay(1) 44 | ); 45 | 46 | // Get the latest selections whenever the model changes 47 | const selections$ = obj$.pipe( 48 | switchMap(h => h.invalidated$.pipe(startWith(h))), 49 | switchMap(h => h.ask(GetLayout)) 50 | ); 51 | 52 | // Print the selections to the DOM 53 | selections$.subscribe(layout => { 54 | const content = document.querySelector("#content"); 55 | content.innerHTML = layout.qSelectionObject.qSelections.map(sel => `${sel.qField}: ${sel.qSelected}`).join("") 56 | content.innerHTML += `
    The average petal length is ${layout.myValue}`; 57 | }); 58 | 59 | // Get a field 60 | const fld$ = app$.pipe( 61 | switchMap(h => h.ask(GetField, "species")), 62 | shareReplay(1) 63 | ); 64 | 65 | // Create a loop of selections by getting the cardinality of the field, 66 | // then running a repeating interval for that cardinality and passing to 67 | // a lowLevelSelect call on the field handle 68 | const selectionLoop$ = fld$.pipe( 69 | switchMap(h => h.ask(GetCardinal), (h, cnt) => ([h, cnt])), 70 | switchMap(([h, cnt]) => interval(1500).pipe( 71 | take(cnt), 72 | map(i => [h, i]), 73 | repeat() 74 | )), 75 | switchMap(([h, i]) => h.ask(LowLevelSelect, [i], false)), 76 | publish() 77 | ); 78 | 79 | // Connect the selection loop 80 | selectionLoop$.connect(); 81 | ``` -------------------------------------------------------------------------------- /test/e2e/global.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { publishReplay, refCount, switchMap } = require("rxjs/operators"); 7 | 8 | var { EngineVersion, OpenDoc } = require("../../dist/global"); 9 | var { connectSession } = require("../../dist"); 10 | var Handle = require("../../dist/_cjs/handle"); 11 | 12 | var { port, image } = require("./config.json"); 13 | 14 | // launch a new container 15 | var container$ = createContainer(image, port); 16 | 17 | var eng$ = container$.pipe( 18 | switchMap(() => { 19 | return connectSession({ 20 | host: "localhost", 21 | port: port, 22 | isSecure: false 23 | }).global$; 24 | }), 25 | publishReplay(1), 26 | refCount() 27 | ); 28 | 29 | function testGlobal() { 30 | describe("Global Class", function() { 31 | before(function(done) { 32 | this.timeout(10000); 33 | container$.subscribe(() => done()); 34 | }); 35 | 36 | describe("engineVersion", function() { 37 | const ev$ = eng$.pipe( 38 | switchMap(handle => handle.ask(EngineVersion)), 39 | publishReplay(1), 40 | refCount() 41 | ); 42 | 43 | it("should return an object with prop 'qComponentVersion'", function(done) { 44 | ev$.subscribe(ev => { 45 | expect(ev).to.have.property("qComponentVersion"); 46 | done(); 47 | }); 48 | }); 49 | 50 | describe("qComponentVersion", function() { 51 | it("should be a string", function(done) { 52 | ev$.subscribe(ev => { 53 | expect(ev.qComponentVersion).to.be.a("string"); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | }); 59 | 60 | describe("openDoc", function() { 61 | const app$ = eng$.pipe( 62 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 63 | publishReplay(1), 64 | refCount() 65 | ); 66 | 67 | it("should return a Handle", function(done) { 68 | app$.subscribe(appH => { 69 | expect(appH).to.be.instanceof(Handle); 70 | done(); 71 | }); 72 | }); 73 | 74 | describe("Returned Handle", function() { 75 | it("should have qClass property of 'Doc'", function(done) { 76 | app$.subscribe(h => { 77 | expect(h.qClass).to.equal("Doc"); 78 | done(); 79 | }); 80 | }); 81 | }); 82 | }); 83 | 84 | after(function(done) { 85 | container$.subscribe(container => 86 | container.kill((err, result) => { 87 | container.remove(); 88 | done(); 89 | }) 90 | ); 91 | }); 92 | }); 93 | } 94 | 95 | module.exports = testGlobal; 96 | -------------------------------------------------------------------------------- /docs/recipes/get-pages-in-sequence.md: -------------------------------------------------------------------------------- 1 | # Get HyperCube Pages in Sequence 2 | [Code Sandbox](https://codesandbox.io/embed/0yzo8ykl2n) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout, GetHyperCubeData } from "rxq/GenericObject"; 8 | import { reduce, shareReplay, startWith, switchMap } from "rxjs/operators"; 9 | import { concat } from "rxjs"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 12 | 13 | // Define the configuration for your session 14 | const config = { 15 | host: "sense.axisgroup.com", 16 | isSecure: true, 17 | appname 18 | }; 19 | 20 | // Connect the session and share the Global handle 21 | const session = connectSession(config); 22 | const global$ = session.global$; 23 | 24 | // Open an app and share the app handle 25 | const app$ = global$.pipe( 26 | switchMap(h => h.ask(OpenDoc, appname)), 27 | shareReplay(1) 28 | ); 29 | 30 | // Create a Generic Object with a hypercube 31 | const obj$ = app$.pipe( 32 | switchMap(h => h.ask(CreateSessionObject, { 33 | "qInfo": { 34 | "qType": "my-object" 35 | }, 36 | "qHyperCubeDef": { 37 | "qDimensions": [ 38 | { 39 | "qDef": { 40 | "qFieldDefs": ["petal_length"] 41 | } 42 | } 43 | ], 44 | "qMeasures": [ 45 | { 46 | "qDef": { 47 | "qDef": "=avg(petal_width)" 48 | } 49 | } 50 | ] 51 | } 52 | })), 53 | shareReplay(1) 54 | ); 55 | 56 | // On invalidation, get layout to validate, then request multiple pages in sequence 57 | const data$ = obj$.pipe( 58 | switchMap(h => h.invalidated$.pipe(startWith(h))), 59 | switchMap(h => h.ask(GetLayout), (h, layout) => [h, layout]), 60 | switchMap(([h, layout]) => { 61 | const totalRows = layout.qHyperCube.qSize.qcy; 62 | const rowsPerPage = 10; 63 | const pageCt = Math.ceil(totalRows / rowsPerPage); 64 | 65 | const pageRequests = new Array(pageCt) 66 | .fill(undefined) 67 | .map((m, i) => h.ask(GetHyperCubeData, "/qHyperCubeDef", [ 68 | { 69 | qTop: i * rowsPerPage, 70 | qLeft: 0, 71 | qWidth: 2, 72 | qHeight: rowsPerPage 73 | } 74 | ])); 75 | 76 | return concat(...pageRequests).pipe( 77 | reduce((acc, curr) => acc.concat(curr)) 78 | ); 79 | }) 80 | ); 81 | 82 | // Print the pages to the DOM in a table 83 | data$.subscribe(pages => { 84 | const data = pages.reduce((acc, page) => acc.concat(page.qMatrix),[]); 85 | 86 | document.querySelector("tbody").innerHTML = data.map(row => { 87 | return ` 88 | ${row[0].qText} 89 | ${row[1].qText} 90 | ` 91 | }).join(""); 92 | }); 93 | 94 | ``` -------------------------------------------------------------------------------- /docs/recipes/get-pages-in-parallel.md: -------------------------------------------------------------------------------- 1 | # Get HyperCube Data Pages in Parallel 2 | [Code Sandbox](https://codesandbox.io/embed/1r55yyy9nq) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout, GetHyperCubeData } from "rxq/GenericObject"; 8 | import { reduce, shareReplay, startWith, switchMap } from "rxjs/operators"; 9 | import { merge } from "rxjs"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 12 | 13 | // Define the configuration for your session 14 | const config = { 15 | host: "sense.axisgroup.com", 16 | isSecure: true, 17 | appname 18 | }; 19 | 20 | // Connect the session and share the Global handle 21 | const session = connectSession(config); 22 | const global$ = session.global$; 23 | 24 | // Open an app and share the app handle 25 | const app$ = global$.pipe( 26 | switchMap(h => h.ask(OpenDoc, appname)), 27 | shareReplay(1) 28 | ); 29 | 30 | // Create a Generic Object with a hypercube 31 | const obj$ = app$.pipe( 32 | switchMap(h => h.ask(CreateSessionObject, { 33 | "qInfo": { 34 | "qType": "my-object" 35 | }, 36 | "qHyperCubeDef": { 37 | "qDimensions": [ 38 | { 39 | "qDef": { 40 | "qFieldDefs": ["petal_length"] 41 | } 42 | } 43 | ], 44 | "qMeasures": [ 45 | { 46 | "qDef": { 47 | "qDef": "=avg(petal_width)" 48 | } 49 | } 50 | ] 51 | } 52 | })), 53 | shareReplay(1) 54 | ); 55 | 56 | // On invalidation, get layout to validate, then request multiple pages in parallel 57 | const data$ = obj$.pipe( 58 | switchMap(h => h.invalidated$.pipe(startWith(h))), 59 | switchMap(h => h.ask(GetLayout), (h, layout) => [h, layout]), 60 | switchMap(([h, layout]) => { 61 | const totalRows = layout.qHyperCube.qSize.qcy; 62 | const rowsPerPage = 10; 63 | const pageCt = Math.ceil(totalRows / rowsPerPage); 64 | 65 | const pageRequests = new Array(pageCt) 66 | .fill(undefined) 67 | .map((m, i) => h.ask(GetHyperCubeData, "/qHyperCubeDef", [ 68 | { 69 | qTop: i * rowsPerPage, 70 | qLeft: 0, 71 | qWidth: 2, 72 | qHeight: rowsPerPage 73 | } 74 | ])); 75 | 76 | return merge(...pageRequests).pipe( 77 | reduce((acc, curr) => acc.concat(curr)) 78 | ); 79 | }) 80 | ); 81 | 82 | // Print the pages to the DOM in a table 83 | data$.subscribe(pages => { 84 | const data = pages.reduce((acc, page) => acc.concat(page.qMatrix),[]); 85 | 86 | document.querySelector("tbody").innerHTML = data.map(row => { 87 | return ` 88 | ${row[0].qText} 89 | ${row[1].qText} 90 | ` 91 | }).join(""); 92 | }); 93 | 94 | ``` -------------------------------------------------------------------------------- /docs/basics/delta.md: -------------------------------------------------------------------------------- 1 | # Delta Mode 2 | 3 | ## What is Delta Mode 4 | Qlik's Engine API has the ability to provide responses in what is known as "delta mode". In delta mode, rather than sending the entire API response back for a call, the Engine will send a list of changes to apply from a previous API call instead. For example, imagine you have received the layout of an app from the Engine in a JSON structure that includes properties like `lastReloadTime`: 5 | ```json 6 | { 7 | qId: "app-id", 8 | title: "My App", 9 | // ...other props 10 | lastReloadTime: "date" 11 | } 12 | ``` 13 | 14 | Now imagine the app is reloaded. This app layout is no longer valid, so you make an API request for another layout. Normally, you would receive the entire object back again, which would included an updated reload time: 15 | ```json 16 | { 17 | qId: "app-id", 18 | title: "My App", 19 | //...other props 20 | lastReloadTime: "new date" 21 | } 22 | ``` 23 | 24 | In this scenario, only one property actually changes from the first layout call to the second: `lastReloadTime`. Therefore, it's redundant to send all of the other info again. Enter delta mode. 25 | 26 | In delta mode, the Engine only sends instructions back on how to update the previously seen value. In this case, it would send a message indicating that the "lastReloadTime" property has been updated, along with its new value. These instructions are similar to the JSON-Patch specification. 27 | 28 | The benefit of using delta mode is that less information will be sent over the network as an app is used, leading to faster API calls. On the other hand, delta mode requires more work on the client to process the changes and keep track of previous values. Realistically, this is a trade-off you will probably not notice in your typical application built with the Engine. 29 | 30 | ## Usage in RxQ 31 | By default, delta mode is not used in RxQ. The library provides two mechanisms for opting into delta mode: 32 | 1. **Session-level**: Delta mode can be enabled across the session for every API call. This is done by setting the session config `delta` property to true: 33 | ```javascript 34 | const session = connectSession({ 35 | host: "localhost", 36 | port: 9076, 37 | delta: true 38 | }) 39 | ``` 40 | 41 | 2. **Method-level**: Rather than opting the entire session into delta mode, you can also selectively pick which methods that you want to leverage delta mode by providing an object with the Qlik class as a key and the method names that should use delta mode. For example, to enable delta mode for GenericObject `GetLayout` and `GetProperties` calls, you could configure your session like so: 42 | ```javascript 43 | const session = connectSession({ 44 | host: "localhost", 45 | port: 9076, 46 | delta: { 47 | GenericObject: ["GetLayout", "GetProperties"] 48 | } 49 | }) 50 | ``` -------------------------------------------------------------------------------- /docs/basics/handle-invalidations.md: -------------------------------------------------------------------------------- 1 | # Handle Invalidations 2 | In QAE, Handles can have properties or calculated values that change while the session is open. This usually happens as a result of API calls - maybe a filter is applied that changes the calculated data you have from a GenericObject, or maybe you update some metadata in your App properties. 3 | 4 | QAE categorizes Handles into two states: valid and invalid. A valid Handle is one in which you have the latest properties or data for it. An invalid Handle is one in which you don't have the latest properties for it – the Handle has changed on the server since you last used it, but you have not pulled the latest information to your app. There is a disconnect between server state and client state; hence, your Handle is invalid. 5 | 6 | Whenever an operation occurs that causes a Handle to go from valid to invalid, the Engine notifies us via the WebSocket connection so that we can respond if needed. A really common use case for this is getting data from a GenericObject. Let's say we have the following stream that gives us data from QAE: 7 | 8 | ```javascript 9 | import { CreateSessionObject } from "rxq/Doc"; 10 | import { GetLayout } from "rxq/GenericObject"; 11 | import { shareReplay, switchMap } from "rxjs/operators"; 12 | 13 | const app$; // assume we have a Doc Handle that we've opened 14 | 15 | const obj$ = app$.pipe( 16 | switchMap(h => h.ask(CreateSessionObject, { 17 | qInfo: { 18 | qType: "session" 19 | }, 20 | myValue: { 21 | qValueExpression: "=sum(Sales)" 22 | } 23 | })), 24 | shareReplay(1) 25 | ); 26 | 27 | const layout$ = obj$.pipe( 28 | switchMap(h => h.ask(GetLayout)) 29 | ); 30 | 31 | layout$.subscribe(console.log); 32 | ``` 33 | 34 | The code above will calculate the layout of our GenericObject once and print it to the console. What if the data model state changes? Suppose we apply a selection that causes our `sum(Sales)` expression to change in value. The layout we received previously will be out of sync with the server. The GenericObject will be invalid. 35 | 36 | To resolve this situation, we need to call `getLayout` again. This will get us the up to date data and validate the GenericObject. 37 | 38 | In RxQ, each Handle comes with an `invalidation$` stream on it. This stream will fire with the Handle any time that the Engine API tells us that the Handle has invalidated. Using this stream, we can rewrite our layout logic above to produce a stream of layouts that will be calculated on every invalidation. We will also add an initial layout call to the stream: 39 | ```javascript 40 | const layouts$ = obj$.pipe( 41 | switchMap(h => h.invalidated$.pipe( 42 | startWith(h) 43 | )), 44 | switchMap(h => h.ask(GetLayout)) 45 | ); 46 | ``` 47 | 48 | Now our `layouts$` stream will stay in sync with the server. -------------------------------------------------------------------------------- /test/e2e/connectSession.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | publishReplay, 8 | refCount, 9 | switchMap, 10 | map, 11 | take 12 | } = require("rxjs/operators"); 13 | var { connectSession } = require("../../dist"); 14 | var Handle = require("../../dist/_cjs/handle"); 15 | 16 | var { port, image } = require("./config.json"); 17 | 18 | // launch a new container 19 | var container$ = createContainer(image, port); 20 | 21 | var session$ = container$.pipe( 22 | map(() => { 23 | return connectSession({ 24 | host: "localhost", 25 | port: port, 26 | isSecure: false 27 | }); 28 | }), 29 | publishReplay(1), 30 | refCount() 31 | ); 32 | 33 | const eng$ = session$.pipe(switchMap(session => session.global$)); 34 | const notifications$ = session$.pipe( 35 | switchMap(session => session.notifications$) 36 | ); 37 | 38 | function testConnect() { 39 | describe("Connect to an engine", function() { 40 | before(function(done) { 41 | this.timeout(10000); 42 | container$.subscribe(() => done()); 43 | }); 44 | 45 | it("should return a Handle", function(done) { 46 | var eng$ = container$.pipe( 47 | switchMap(() => { 48 | return connectSession({ 49 | host: "localhost", 50 | port: port, 51 | isSecure: false 52 | }).global$; 53 | }), 54 | publishReplay(1), 55 | refCount() 56 | ); 57 | 58 | eng$.subscribe(h => { 59 | expect(h).to.be.instanceof(Handle); 60 | done(); 61 | }); 62 | }); 63 | 64 | it("should return a Handle when given a URL to connect with", function(done) { 65 | container$ 66 | .pipe( 67 | map(() => { 68 | return connectSession({ 69 | url: `ws://localhost:${port}/app` 70 | }); 71 | }), 72 | switchMap(session => session.global$), 73 | take(1) 74 | ) 75 | .subscribe(h => { 76 | expect(h).to.be.instanceof(Handle); 77 | done(); 78 | }); 79 | }); 80 | 81 | describe("Returned Handle", function() { 82 | it("should have qClass property of 'Global'", function(done) { 83 | eng$.subscribe(h => { 84 | expect(h.qClass).to.equal("Global"); 85 | done(); 86 | }); 87 | }); 88 | }); 89 | 90 | after(function(done) { 91 | container$.subscribe(container => 92 | container.kill((err, result) => { 93 | container.remove(); 94 | done(); 95 | }) 96 | ); 97 | }); 98 | }); 99 | } 100 | 101 | module.exports = testConnect; 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxq", 3 | "version": "2.0.4", 4 | "sideEffects": false, 5 | "description": "A reactive wrapper for the Qlik Analytics Platform APIs", 6 | "main": "index.js", 7 | "qix-version": "12.329.0", 8 | "scripts": { 9 | "build-qix-methods": "node scripts/build-qix-methods.js", 10 | "compile-cjs": "node scripts/compile_cjs.js", 11 | "compile-esm5": "node scripts/compile_esm5.js", 12 | "compile-esm2015": "node scripts/compile_esm2015.js", 13 | "compile-all": "npm run compile-cjs && npm run compile-esm5 && npm run compile-esm2015", 14 | "make-packages": "node scripts/make-packages.js", 15 | "build": "node scripts/build-browser", 16 | "build-min": "node scripts/build-browser --min", 17 | "build-dist": "npm run compile-all && npm run make-packages && npm run build && npm run build-min", 18 | "build-release": "npm run build-dist && cp -R dist/build/ rxq-build && zip rxq-build.zip rxq-build/* && rm -rf rxq-build/", 19 | "test-unit": "mocha test/unit/*.spec.js", 20 | "test-e2e": "mocha test/e2e/*.spec.js", 21 | "test": "npm run test-unit && npm run test-e2e", 22 | "test-compile": "mocha --compilers js:babel-core/register test/unit/*-spec.js", 23 | "docs-build": "gitbook build", 24 | "docs-serve": "gitbook serve" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/axisgroup/RxQ.git" 29 | }, 30 | "keywords": [ 31 | "qap", 32 | "qix", 33 | "engine", 34 | "engineAPI", 35 | "qrs" 36 | ], 37 | "author": "Axis Group", 38 | "license": "ISC", 39 | "homepage": "https://github.com/axisgroup/RxQ#readme", 40 | "devDependencies": { 41 | "babel-core": "^6.26.3", 42 | "babel-loader": "^7.1.4", 43 | "babel-plugin-add-module-exports": "^0.2.1", 44 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 45 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 46 | "babel-plugin-transform-runtime": "^6.15.0", 47 | "babel-polyfill": "^6.16.0", 48 | "babel-preset-env": "^1.7.0", 49 | "babel-preset-es2015": "^6.16.0", 50 | "babel-regenerator-runtime": "^6.5.0", 51 | "babel-register": "^6.26.0", 52 | "chai": "^4.1.2", 53 | "chai-generator": "^2.1.0", 54 | "chai-spies": "^1.0.0", 55 | "dockerode": "^2.5.3", 56 | "fs-extra": "^4.0.2", 57 | "gitbook-cli": "^2.3.2", 58 | "mocha": "^4.0.1", 59 | "mock-socket": "^7.0.0", 60 | "raw-loader": "^0.5.1", 61 | "rimraf": "^2.6.2", 62 | "rxjs": "^6.2.0", 63 | "string-replace-webpack-plugin": "^0.1.3", 64 | "webpack": "^4.10.2" 65 | }, 66 | "dependencies": { 67 | "babel-runtime": "^6.18.0", 68 | "https-browserify": "0.0.1", 69 | "immer": "^2.1.1", 70 | "stream-http": "~2.0.1", 71 | "ws": "^1.1.1" 72 | }, 73 | "peerDependencies": { 74 | "rxjs": "^6.2.0" 75 | }, 76 | "bugs": { 77 | "url": "https://github.com/axisgroup/RxQ/issues" 78 | }, 79 | "browser": { 80 | "ws": false 81 | }, 82 | "directories": { 83 | "example": "examples" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /docs/introduction/why-rx.md: -------------------------------------------------------------------------------- 1 | # Why Rx? 2 | [Reactive programming](https://en.wikipedia.org/wiki/Reactive_programming) is a declarative style of programming that enables developers to define variables as entities that change over time, with their behavior and interdependencies clearly defined. This approach is best represented in the following pseudocode examples: 3 | 4 | *Non-reactive* 5 | ``` 6 | a = 1; 7 | b = a + 1; 8 | a = 2; 9 | 10 | print a; // -> 2 11 | print b; // -> 2 12 | ``` 13 | 14 | *Reactive* 15 | ``` 16 | a = 1; 17 | b = a + 1; 18 | a = 2; 19 | 20 | print a; // -> 2 21 | print b; // -> 3 22 | ``` 23 | 24 | In the reactive example, the variable `b` is declared as depending on `a`, so when `a` changes, `b` necessarily changes. 25 | 26 | Because of this feature, reactive programming is useful in highly interactive interfaces, especially when complex relationships exist between various variables. It also lends itself to asynchronous operations, where time delays affect when and how variables in a program change. When used properly, Rx enables scalable and maintainable code for complex, dynamic applications. 27 | 28 | Let's take a simple example. [Say you want to create a component that can be dragged and dropped around the screen.](https://codesandbox.io/embed/9ol0rvokpo) The Rx code for this is concise, easy to read, and easy to modify: 29 | ```javascript 30 | import { fromEvent } from "rxjs/observable/fromEvent"; 31 | import { switchMap, takeUntil } from "rxjs/operators"; 32 | 33 | const box = document.querySelector("#box"); 34 | 35 | const mousedown$ = fromEvent(box, "mousedown"); 36 | const mouseup$ = fromEvent(document, "mouseup"); 37 | const mousemove$ = fromEvent(document, "mousemove"); 38 | 39 | const move$ = mousedown$.pipe( 40 | switchMap(() => mousemove$.pipe( 41 | takeUntil(mouseup$) 42 | )) 43 | ); 44 | 45 | move$.subscribe(evt => { 46 | const top = parseInt(box.style.top); 47 | const left = parseInt(box.style.left); 48 | box.style.top = `${top + evt.movementY}px`; 49 | box.style.left = `${left + evt.movementX}px`; 50 | }); 51 | ``` 52 | 53 | For more on Rx, we highly recommend this guide to get started: [The introduction to Reactive Programming you've been missing](https://gist.github.com/staltz/868e7e9bc2a7b8c1f754), by [André Staltz](https://gist.github.com/staltz). 54 | 55 | Or if you're looking for a quicker summary, try this [shorter read](https://branch-blog.qlik.com/what-is-reactive-programming-a1e82cf28575). 56 | 57 | ## Rx & Qlik 58 | Because Rx works so well with dynamic applications, it pairs well with Qlik and it's engine. At its core, the Qlik Associative Engine (QAE) can be thought of as a reactive interface. It models data into a **state** that is modified by interactions like filtering data, causing the state of the model to update and any existing calculations to be recalculated based on this new state. In other words, the data model state in the engine is a dynamic entity that can change over time. This fits perfectly with Rx's strengths! 59 | 60 | RxQ bridges the gap between RxJS and QAE, enabling developers to leverage reactive programming when working with Qlik. -------------------------------------------------------------------------------- /docs/recipes/make-lb-selections.md: -------------------------------------------------------------------------------- 1 | # Make Selections in a Listbox 2 | [Code Sandbox](https://codesandbox.io/embed/3kj6nw46q) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout, SelectListObjectValues } from "rxq/GenericObject"; 8 | import { filter, map, publish, repeat, shareReplay, startWith, switchMap, take, withLatestFrom } from "rxjs/operators"; 9 | import { fromEvent } from "rxjs"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 12 | 13 | // Define the configuration for your session 14 | const config = { 15 | host: "sense.axisgroup.com", 16 | isSecure: true, 17 | appname 18 | }; 19 | 20 | // Connect the session and share the Global handle 21 | const session = connectSession(config); 22 | const global$ = session.global$; 23 | 24 | // Open an app and share the app handle 25 | const app$ = global$.pipe( 26 | switchMap(h => h.ask(OpenDoc, appname)), 27 | shareReplay(1) 28 | ); 29 | 30 | // Create a Generic Object with a metric 31 | const obj$ = app$.pipe( 32 | switchMap(h => h.ask(CreateSessionObject, { 33 | "qInfo": { 34 | "qType": "my-object" 35 | }, 36 | "myValue": { 37 | "qValueExpression": "=avg(petal_length)" 38 | } 39 | })), 40 | shareReplay(1) 41 | ); 42 | 43 | // Get the latest selections whenever the model changes 44 | const metricLayouts$ = obj$.pipe( 45 | switchMap(h => h.invalidated$.pipe(startWith(h))), 46 | switchMap(h => h.ask(GetLayout)) 47 | ); 48 | 49 | // Print the selections to the DOM 50 | metricLayouts$.subscribe(layout => { 51 | document.querySelector("#metric").innerHTML = `
    The average petal length is ${layout.myValue}`; 52 | }); 53 | 54 | // Create a Generic Object with a list object for the field "species" 55 | const lb$ = app$.pipe( 56 | switchMap(h => h.ask(CreateSessionObject, { 57 | "qInfo": { 58 | "qType": "my-listbox" 59 | }, 60 | "qListObjectDef": { 61 | "qDef": { 62 | "qFieldDefs": ["species"] 63 | }, 64 | "qInitialDataFetch": [ 65 | { 66 | "qTop": 0, 67 | "qLeft": 0, 68 | "qWidth": 1, 69 | "qHeight": 100 70 | } 71 | ] 72 | } 73 | })), 74 | shareReplay(1) 75 | ); 76 | 77 | // Get a stream of list object layouts 78 | const lbLayouts$ = lb$.pipe( 79 | switchMap(h => h.invalidated$.pipe(startWith(h))), 80 | switchMap(h => h.ask(GetLayout)) 81 | ); 82 | 83 | // Render the list object to the page in an unordered list 84 | lbLayouts$.subscribe(layout => { 85 | const data = layout.qListObject.qDataPages[0].qMatrix; 86 | document.querySelector("ul").innerHTML = data.map(item => `
  • 87 | ${item[0].qText} 88 |
  • `).join(""); 89 | }); 90 | 91 | // Select values when a user clicks on them 92 | const select$ = fromEvent(document.querySelector("body"), "click").pipe( 93 | filter(evt => evt.target.hasAttribute("data-qno")), 94 | map(evt => parseInt(evt.target.getAttribute("data-qno"))), 95 | withLatestFrom(lb$), 96 | switchMap(([qno, h]) => h.ask(SelectListObjectValues, "/qListObjectDef", [qno], true)), 97 | publish() 98 | ); 99 | 100 | select$.connect(); 101 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RxQ 2 | **RxQ** is a JavaScript library that provides utilities to link [**RxJS**](https://github.com/ReactiveX/rxjs), a JavaScript implementation of Rx, with the Qlik Associative Engine (QAE). RxQ can be used to build complex, interactive solutions on top of QAE using the reactive programming paradigm. RxQ can be used in projects via npm like so: 3 | ``` 4 | $ npm install rxq 5 | ``` 6 | 7 | *Note that **RxJS ^6.2.0** is a peer dependency of **RxQ**; It will not be automatically installed when you install RxQ. If you do not already have **RxJS ^6.2.0** installed to your project, be sure to include it by running:* 8 | ``` 9 | $ npm install rxjs 10 | ``` 11 | 12 | ## Usage and Documentation 13 | Documentation for RxQ is hosted on [http://opensrc.axisgroup.com/rxq/docs](http://opensrc.axisgroup.com/rxq/docs/). We highly recommend reviewing the list of Recipes for examples of usage. 14 | 15 | ## Quick Start 16 | Want to play with it immediately? [Try forking this sandbox.](https://codesandbox.io/embed/k3mvn2k815) 17 | 18 | ## Qlik Support 19 | As of v2.0.2, the following APIs are supported: 20 | - Engine 12.260.0 21 | 22 | Custom builds for other versions of the Qlik Associative Engine can be generated using the included build scripts. See the build section. 23 | 24 | 25 | ## Building RxQ 26 | RxQ has several auto-generated components that build the source code and compile it into the distributed package for NPM. It leverages Qlik Engine v12.181.0 to generate method names. The steps are: 27 | 1) Getting the correct QIX Engine schemas and generating operators for all API methods for the desired engine version 28 | 2) Converting all source code into distribution modules and move them to the distribution folder 29 | 3) Creating the package.json files for the distribution folder 30 | 31 | Each of these steps can be triggered using npm scripts in the repository: 32 | 33 | ### Step 1: Getting Engine schemas and generating operators 34 | `npm run build-qix-methods` uses Qlik Core to spin up an Engine and pull the API schema that will be used to generate enums for all of the API methods. 35 | 36 | ### Step 2: Converting all source code into distribution modules 37 | `npm run compile-cjs` compiles the CommonJS modules. 38 | 39 | `npm run compile-esm5` compiles the ESM5 modules. 40 | 41 | `npm run compile-esm` compiles the ESM modules. 42 | 43 | `npm run build` compiles the browser bundle. 44 | 45 | `npm run build-min` compiles the minified browser bundle. 46 | 47 | ### Step 3: Creating the package.json files for distribution 48 | `npm run make-packages` creates and stores the package.json files. 49 | 50 | ### Rebuilding the Distribution Folder 51 | It is common to edit source code of RxQ and then execute steps 2 and 3 to rebuild the distribution folder. Those steps can be done in a single command with: 52 | `npm run build-dist`. 53 | 54 | The final package for distribution is stored in a sub-directory called `dist`. The NPM package should be published from this directory, NOT from the parent level repository which contains the source code. 55 | 56 | ## Testing RxQ 57 | `npm run test-unit` will run the unit tests. 58 | 59 | `npm run test-e2e` will run the end to end tests. These tests require Docker and the Qlik Core image associated with the engine version in package.json. For example, for version 12.207.0 of the Engine, the tests need the `qlikcore/engine:12.207.0` image. This image can be pulled from Docker like so: `docker pull qlikcore/engine:12.207.0`. 60 | -------------------------------------------------------------------------------- /docs/basics/reusing-handles.md: -------------------------------------------------------------------------------- 1 | # Reusing Handles 2 | It is common to make multiple API calls on a single Handle. Hence, we usually save off Handles in their own variable that can be reused. [However, Observables in RxJS are cold by default – they create a new producer for each subscriber](https://medium.com/@benlesh/hot-vs-cold-observables-f8094ed53339). In RxQ land, that means that each subscriber to an API call will cause it to run. 3 | 4 | Let's illustrate with an example. Assume you want to open an app and use the Doc Class to get both the app properties and the app layout. You write the following: 5 | ```javascript 6 | import { connectSession } from "rxq"; 7 | import { EngineVersion, OpenDoc } from "rxq/Global"; 8 | import { GetAppProperties, GetAppLayout } from "rxq/Doc"; 9 | import { switchMap } from "rxjs/operators"; 10 | 11 | const session = connectSession({ 12 | host: "localhost", 13 | port: 4848, 14 | isSecure: false 15 | }); 16 | 17 | const doc$ = session.global$.pipe( 18 | switchMap(handle => handle.ask(OpenDoc)) 19 | ); 20 | 21 | const appProps$ = doc$.pipe( 22 | switchMap(handle => handle.ask(GetAppProperties)) 23 | ); 24 | 25 | const appLayout$ = doc$.pipe( 26 | switchMap(handle = handle.ask(GetAppLayout)) 27 | ); 28 | 29 | appProps$.subscribe(version => { 30 | console.log(version); 31 | }); 32 | 33 | appLayout$.subscribe(doclist => { 34 | console.log(doclist); 35 | }); 36 | ``` 37 | 38 | In the code above, we execute two API calls leveraging the `doc$` Observable. This Observable is getting two subscribers via the `appProps$` and `appLayout$` subscriptions. **Therefore, this will execute the `doc$` Observable twice, running the OpenDoc command twice!** This is rarely what we want when working with QAE, especially when creating new Generic Objects. Instead, we want to share the Handle across subscribers. This can be done by multicasting the Handle Observable. 39 | 40 | [There are many ways to multicast Observables in RxJS.](https://blog.angularindepth.com/rxjs-understanding-the-publish-and-share-operators-16ea2f446635) For RxQ usage, we often find the operator `shareReplay(1)` does the trick for us. This operator: 41 | * will create the Observable producer when going from 0 to 1 observers 42 | * will share the latest seen value with any late subscribers 43 | 44 | In most RxQ examples, you will see reused handles written like so: 45 | ```javascript 46 | doc$.pipe( 47 | shareReplay(1) 48 | ); 49 | ``` 50 | 51 | Our example from before becomes: 52 | ```javascript 53 | import { connectSession } from "rxq"; 54 | import { EngineVersion, OpenDoc } from "rxq/Global"; 55 | import { GetAppProperties, GetAppLayout } from "rxq/Doc"; 56 | import { switchMap, shareReplay } from "rxjs/operators"; 57 | 58 | const session = connectSession({ 59 | host: "localhost", 60 | port: 4848, 61 | isSecure: false 62 | }); 63 | 64 | const doc$ = session.global$.pipe( 65 | switchMap(handle => handle.ask(OpenDoc)), 66 | shareReplay(1) 67 | ); 68 | 69 | const appProps$ = doc$.pipe( 70 | switchMap(handle => handle.ask(GetAppProperties)) 71 | ); 72 | 73 | const appLayout$ = doc$.pipe( 74 | switchMap(handle = handle.ask(GetAppLayout)) 75 | ); 76 | 77 | appProps$.subscribe(version => { 78 | console.log(version); 79 | }); 80 | 81 | appLayout$.subscribe(doclist => { 82 | console.log(doclist); 83 | }); 84 | ``` 85 | 86 | Now, our `appProps$` and `appLayout$` Observables will share the Global Handle produced by `doc$`. We should multicast any responses from the API that will be used by multiple observers. -------------------------------------------------------------------------------- /docs/basics/making-api-calls.md: -------------------------------------------------------------------------------- 1 | # Making API Calls 2 | As discussed in [Core Concepts](../introduction/core-concepts.md), RxQ provides Handle instances that have an `ask` method for executing an API call and then return an Observable for the response. 3 | 4 | Any API method name can be entered via a string. However, the Qlik Engine API is vast so RxQ provides exports of method name enums for each Qlik class. 5 | 6 | Let's use the `EngineVersion` method from Qlik's Global class as an example. We can import the `EngineVersion` enum from RxQ like so: 7 | ```javascript 8 | import { EngineVersion } from "rxq/Global"; 9 | ``` 10 | 11 | To use it, we call a Global Handle's ask method with it. Then, we can subscribe to get the engine version back: 12 | ```javascript 13 | const version$ = globalHandle.ask(EngineVersion); 14 | 15 | version$.subscribe((version) => { 16 | console.log(version); 17 | }); 18 | ``` 19 | 20 | There's just one problem here: how do we get the Global Handle in order to make the call? Let's review. 21 | 22 | ## Getting Handles 23 | Handles are provided to us by QAE. Therefore, we can only receive them asychronously through some sort of API call. The Engine has several API calls that will return Handles, such as: 24 | * [OpenDoc](http://help.qlik.com/en-US/sense-developer/November2017/Subsystems/EngineAPI/Content/Classes/GlobalClass/Global-class-OpenDoc-method.htm), which will return a Doc Handle from a Global Handle 25 | * [GetField](http://help.qlik.com/en-US/sense-developer/November2017/Subsystems/EngineAPI/Content/Classes/AppClass/App-class-GetField-method.htm), which will return a Field Handle from a Doc Handle 26 | 27 | RxQ automatically parses the results of Engine API calls and produces Handles for you as needed. However, this still has to happen asynchronously, so we have to write asynchronous logic to connect a Handle with an API call. This is where higher order Observables come into play. 28 | 29 | ## Leveraging Higher Order Observables for API Calls 30 | Higher Order Observables are essentially Observables of Observables. They allow us to create asynchronous data streams based on other asynchronous data streams. This concept is pertitent to us when making API calls, since we are trying to produce an async API call from an asynchronously provided Handle. 31 | 32 | RxJS makes handling these higher order observables easy using operators like [mergeMap](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-mergeMap), [concatMap](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-concatMap), and [switchMap](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-switchMap) to flatten them into normal Observables. 33 | 34 | When using `connectSession`, the resulting Observable provides the Global Handle for the established session. By combining this Observable with the `switchMap` operator, we can get our engine version like so: 35 | 36 | ```javascript 37 | import { connectSession } from "rxq"; 38 | import { EngineVersion } from "rxq/Global"; 39 | import { switchMap } from "rxjs/operators"; 40 | 41 | const session = connectSession({ 42 | host: "localhost", 43 | port: 4848, 44 | isSecure: false 45 | }); 46 | 47 | const global$ = session.global$; 48 | 49 | const version$ = global$.pipe( 50 | switchMap(handle => handle.ask(EngineVersion)) 51 | ); 52 | 53 | version$.subscribe(version => { 54 | console.log(version); 55 | }); 56 | ``` 57 | 58 | We commonly use `switchMap` when utilizing RxQ because we often only care about making an API call on the latest Handle provided. For more on higher order observables, [we recommend this course](https://egghead.io/courses/use-higher-order-observables-in-rxjs-effectively). -------------------------------------------------------------------------------- /docs/recipes/search-lb.md: -------------------------------------------------------------------------------- 1 | # Search a Listbox 2 | [Code Sandbox](https://codesandbox.io/embed/843q0ny400) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout, SearchListObjectFor, SelectListObjectValues } from "rxq/GenericObject"; 8 | import { filter, map, publish, repeat, shareReplay, startWith, switchMap, withLatestFrom } from "rxjs/operators"; 9 | import { fromEvent } from "rxjs"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 12 | 13 | // Define the configuration for your session 14 | const config = { 15 | host: "sense.axisgroup.com", 16 | isSecure: true, 17 | appname 18 | }; 19 | 20 | // Connect the session and share the Global handle 21 | const session = connectSession(config); 22 | const global$ = session.global$; 23 | 24 | // Open an app and share the app handle 25 | const app$ = global$.pipe( 26 | switchMap(h => h.ask(OpenDoc, appname)), 27 | shareReplay(1) 28 | ); 29 | 30 | // Create a Generic Object with a metric 31 | const obj$ = app$.pipe( 32 | switchMap(h => h.ask(CreateSessionObject, { 33 | "qInfo": { 34 | "qType": "my-object" 35 | }, 36 | "myValue": { 37 | "qValueExpression": "=avg(petal_length)" 38 | } 39 | })), 40 | shareReplay(1) 41 | ); 42 | 43 | // Get the latest selections whenever the model changes 44 | const metricLayouts$ = obj$.pipe( 45 | switchMap(h => h.invalidated$.pipe(startWith(h))), 46 | switchMap(h => h.ask(GetLayout)) 47 | ); 48 | 49 | // Print the selections to the DOM 50 | metricLayouts$.subscribe(layout => { 51 | document.querySelector("#metric").innerHTML = `
    The average petal length is ${layout.myValue}`; 52 | }); 53 | 54 | // Create a Generic Object with a list object for the field "species" 55 | const lb$ = app$.pipe( 56 | switchMap(h => h.ask(CreateSessionObject, { 57 | "qInfo": { 58 | "qType": "my-listbox" 59 | }, 60 | "qListObjectDef": { 61 | "qDef": { 62 | "qFieldDefs": ["species"] 63 | }, 64 | "qInitialDataFetch": [ 65 | { 66 | "qTop": 0, 67 | "qLeft": 0, 68 | "qWidth": 1, 69 | "qHeight": 100 70 | } 71 | ] 72 | } 73 | })), 74 | shareReplay(1) 75 | ); 76 | 77 | // Get a stream of list object layouts 78 | const lbLayouts$ = lb$.pipe( 79 | switchMap(h => h.invalidated$.pipe(startWith(h))), 80 | switchMap(h => h.ask(GetLayout)) 81 | ); 82 | 83 | // Render the list object to the page in an unordered list 84 | lbLayouts$.subscribe(layout => { 85 | const data = layout.qListObject.qDataPages[0].qMatrix; 86 | document.querySelector("ul").innerHTML = data.map(item => `
  • 87 | ${item[0].qText} 88 |
  • `).join(""); 89 | }); 90 | 91 | // Select values when a user clicks on them 92 | const select$ = fromEvent(document.querySelector("body"), "click").pipe( 93 | filter(evt => evt.target.hasAttribute("data-qno")), 94 | map(evt => parseInt(evt.target.getAttribute("data-qno"))), 95 | withLatestFrom(lb$), 96 | switchMap(([qno, h]) => h.ask(SelectListObjectValues, "/qListObjectDef", [qno], true)), 97 | publish() 98 | ); 99 | 100 | select$.connect(); 101 | 102 | // Search the listbox when text is entered to the input 103 | const search$ = fromEvent(document.querySelector("input"), "keyup").pipe( 104 | map(evt => evt.target.value), 105 | withLatestFrom(lb$, (val, h) => [val, h]), 106 | switchMap(([val, h]) => h.ask(SearchListObjectFor, "/qListObjectDef", val)), 107 | publish() 108 | ); 109 | 110 | search$.connect(); 111 | ``` -------------------------------------------------------------------------------- /docs/recipes/offline.md: -------------------------------------------------------------------------------- 1 | # Offline 2 | [Code Sandbox](https://codesandbox.io/embed/o5kwmv77w9) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { GetActiveDoc, OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { GetLayout, SelectListObjectValues } from "rxq/GenericObject"; 8 | import { fromEvent, merge } from "rxjs"; 9 | import { filter, map, mapTo, publish, shareReplay, startWith, switchMap, take, tap, withLatestFrom } from "rxjs/operators"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf"; 12 | 13 | // Event for when app goes on and offline 14 | const online$ = merge( 15 | fromEvent(window, "online").pipe(mapTo(true)), 16 | fromEvent(window, "offline").pipe(mapTo(false)) 17 | ).pipe( 18 | startWith(true) 19 | ); 20 | 21 | // When going on or offline, show/hide offline banner 22 | online$.subscribe(status => { 23 | if (status) { 24 | document.querySelector("#offline").classList.add("hidden"); 25 | } 26 | else { 27 | document.querySelector("#offline").classList.remove("hidden"); 28 | } 29 | }); 30 | 31 | // Define a session config 32 | const config = { 33 | host: "sense.axisgroup.com", 34 | isSecure: true, 35 | appname 36 | }; 37 | 38 | // When going online, connect a new session 39 | const global$ = online$.pipe( 40 | filter(f => f), 41 | switchMap(() => connectSession(config).global$), 42 | shareReplay(1) 43 | ); 44 | 45 | // Open an app 46 | const app$ = global$.pipe( 47 | switchMap(h => h.ask(OpenDoc, appname)), 48 | shareReplay(1) 49 | ); 50 | 51 | // Calculate a value on invalidation 52 | const layouts$ = app$.pipe( 53 | switchMap(h => h.ask(CreateSessionObject, { 54 | "qInfo": { 55 | "qType": "custom" 56 | }, 57 | "value": { 58 | "qValueExpression": "=avg(petal_length)" 59 | } 60 | })), 61 | switchMap(h => h.invalidated$.pipe(startWith(h))), 62 | switchMap(h => h.ask(GetLayout)) 63 | ); 64 | 65 | // Print the value to the DOM 66 | layouts$.subscribe(layout => { 67 | document.querySelector("#metric").innerHTML = layout.value; 68 | }); 69 | 70 | // Create a Generic Object with a list object for the field species 71 | const listbox$ = app$.pipe( 72 | switchMap(h => h.ask(CreateSessionObject, { 73 | "qInfo": { 74 | "qType": "my-listbox" 75 | }, 76 | "qListObjectDef": { 77 | "qDef": { 78 | "qFieldDefs": ["species"] 79 | }, 80 | "qInitialDataFetch": [ 81 | { 82 | "qTop": 0, 83 | "qLeft": 0, 84 | "qWidth": 1, 85 | "qHeight": 100 86 | } 87 | ] 88 | } 89 | })), 90 | shareReplay(1) 91 | ); 92 | 93 | // Get a stream of listbox layouts 94 | const listboxLayout$ = listbox$.pipe( 95 | switchMap(h => h.invalidated$.pipe(startWith(h))), 96 | switchMap(h => h.ask(GetLayout)) 97 | ); 98 | 99 | // Render the list object to the page in an unordered list 100 | listboxLayout$.subscribe(layout => { 101 | const data = layout.qListObject.qDataPages[0].qMatrix; 102 | document.querySelector("ul").innerHTML = data.map(item => `
  • 103 | ${item[0].qText} 104 |
  • `).join(""); 105 | }); 106 | 107 | // Select values when a user clicks on them 108 | const select$ = fromEvent(document.querySelector("ul"), "click").pipe( 109 | filter(evt => evt.target.hasAttribute("data-qno")), 110 | map(evt => parseInt(evt.target.getAttribute("data-qno"))), 111 | withLatestFrom(listbox$), 112 | switchMap(([qno, h]) => h.ask(SelectListObjectValues, "/qListObjectDef", [qno], true)), 113 | publish() 114 | ); 115 | 116 | select$.connect(); 117 | ``` -------------------------------------------------------------------------------- /docs/introduction/core-concepts.md: -------------------------------------------------------------------------------- 1 | # Core Concepts 2 | 3 | RxQ provides functions that create Observables around Qlik Associative Engine (QAE) API calls. An API call to QAE happens asynchronously over a network, with some sort of value being returned later in time. These could be API calls like getting the engine version being used, opening a document, or calculating a value from a data model. 4 | 5 | The functions provided by RxQ are capable of making these API calls and returning the responses via Observables. A connection function is provided as well which will return an Observable representing the establishment of a session with QAE. 6 | 7 | ## QAE Background 8 | To better understand how this works, a basic understanding of how Qlik's Engine API works is helpful. The Engine API uses the JSON-RPC 2.0 protocol via a WebSocket for bidirectional communication. Messages can be sent to the Engine to initiate API calls. Responses can be received from the Engine. The Engine can also push messages without a preceding request. 9 | 10 | The Engine has various classes that can be interacted with via API calls. For example, the Global class represents a session with the Engine and has API methods for getting the engine version and opening a document. A Doc class exists for applications that are opened. This class has methods for application operations like clearing selections and getting app properties. 11 | 12 | When making an API call to the Engine, the call must tell the Engine what method to use and on what class instance it should be executed on. This class instance is referred to as a Handle. For exmaple, when opening a document in QAE, the Engine will return an identifier for a Handle for the opened document. The developer can then make API calls on this document by referencing this Handle identifier. 13 | 14 | For a more guided and in-depth review of these concepts, try this [Engine Tutorial](http://playground.qlik.com/learn/engine-tutorial/101.%20What%20is%20QIX%20and%20Why%20Should%20You%20Care.html). 15 | 16 | ## Using RxQ to Make API Calls 17 | In RxQ, Handles are provided with an `ask` method that will accept a method name and parameters and execute an API call to the Engine. An Observable is returned that will provide the response and complete. Method names are provided as strings; for convenience, the list of possible methods in the Engine API schema are provided as enums. [Let's use the "EngineVersion" method of Qlik's "Global" class as an example.](http://help.qlik.com/en-US/sense-developer/November2017/Subsystems/EngineAPI/Content/Classes/GlobalClass/Global-class-EngineVersion-method.htm) 18 | 19 | To make this call in RxQ, we can import the `EngineVersion` enum from RxQ and use it with a Global Handle's `ask` method. The `EngineVersion` method takes no parameters, so we don't need any other inputs. This call will return an Observable that can make the API call and return the response. We can subscribe to this Observable to execute it and get the response. 20 | ```javascript 21 | import { EngineVersion } from "rxq/Global"; 22 | 23 | const version$ = myGlobalHandle.ask(EngineVersion); 24 | 25 | version$.subscribe((version) => { 26 | console.log(`The version of the Engine is ${version}`); 27 | }); 28 | ``` 29 | 30 | If a method takes parameters, we just add them as arguments to our function call. For example, to open a document called "Sales.qvf", we would write: 31 | ```javascript 32 | const app$ = myGlobalHandle.ask(OpenDoc, "Sales.qvf"); 33 | ``` 34 | 35 | **This is essentially the core of RxQ: run a function that creates an Observable for a Qlik Engine API call response.** 36 | 37 | You may be wondering how to get Handles from the Engine to feed into these functions. This process is detailed in the [Making API Calls](../basics/making-api-calls.md) section. -------------------------------------------------------------------------------- /test/e2e/genericBookmark.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | publish, 8 | publishReplay, 9 | refCount, 10 | shareReplay, 11 | switchMap, 12 | take, 13 | tap, 14 | withLatestFrom 15 | } = require("rxjs/operators"); 16 | 17 | var { OpenDoc } = require("../../dist/global"); 18 | var { connectSession } = require("../../dist"); 19 | var Handle = require("../../dist/_cjs/handle"); 20 | 21 | var { CreateBookmark } = require("../../dist/doc"); 22 | var { GetProperties, SetProperties } = require("../../dist/genericBookmark"); 23 | 24 | var { port, image } = require("./config.json"); 25 | 26 | // launch a new container 27 | var container$ = createContainer(image, port); 28 | 29 | var eng$ = container$.pipe( 30 | switchMap(() => { 31 | return connectSession({ 32 | host: "localhost", 33 | port: port, 34 | isSecure: false 35 | }).global$; 36 | }), 37 | publishReplay(1), 38 | refCount() 39 | ); 40 | 41 | const app$ = eng$.pipe( 42 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 43 | publishReplay(1), 44 | refCount() 45 | ); 46 | 47 | const bkmk$ = app$.pipe( 48 | switchMap(handle => 49 | handle.ask(CreateBookmark, { 50 | qInfo: { qType: "test-bkmk" }, 51 | foo: "bar" 52 | }) 53 | ), 54 | shareReplay(1) 55 | ); 56 | 57 | function testGenericBookmark() { 58 | describe("GenericBookmark Class", function() { 59 | before(function(done) { 60 | this.timeout(10000); 61 | container$.subscribe(() => done()); 62 | }); 63 | 64 | describe("GetProperties", function() { 65 | const props$ = bkmk$.pipe( 66 | switchMap(h => h.ask(GetProperties)), 67 | shareReplay(1) 68 | ); 69 | 70 | it("should return an object", done => { 71 | props$.subscribe(props => { 72 | expect(props).to.be.a("object"); 73 | done(); 74 | }); 75 | }); 76 | 77 | it("should return a property 'foo' with value 'bar'", done => { 78 | props$.subscribe(props => { 79 | expect(props.foo).to.equal("bar"); 80 | done(); 81 | }); 82 | }); 83 | }); 84 | 85 | describe("SetProperties", function() { 86 | const setProps$ = bkmk$.pipe( 87 | switchMap(h => 88 | h.ask(SetProperties, { 89 | qInfo: { 90 | qType: "test-bkmk" 91 | }, 92 | foo: "baz" 93 | }) 94 | ), 95 | publish() 96 | ); 97 | 98 | it("should cause an invalidation", done => { 99 | bkmk$ 100 | .pipe( 101 | switchMap(h => h.invalidated$), 102 | take(1) 103 | ) 104 | .subscribe(i => { 105 | done(); 106 | }); 107 | 108 | setProps$.connect(); 109 | }); 110 | 111 | it("should change the 'foo' property to 'baz'", done => { 112 | bkmk$ 113 | .pipe( 114 | switchMap(h => h.invalidated$), 115 | switchMap(h => h.ask(GetProperties)) 116 | ) 117 | .subscribe(props => { 118 | expect(props.foo).to.equal("baz"); 119 | done(); 120 | }); 121 | 122 | setProps$.connect(); 123 | }); 124 | }); 125 | 126 | after(function(done) { 127 | container$.subscribe(container => 128 | container.kill((err, result) => { 129 | container.remove(); 130 | done(); 131 | }) 132 | ); 133 | }); 134 | }); 135 | } 136 | 137 | module.exports = testGenericBookmark; 138 | -------------------------------------------------------------------------------- /docs/recipes/apply-patches.md: -------------------------------------------------------------------------------- 1 | # Apply Patches to an Object 2 | [Code Sandbox](https://codesandbox.io/embed/2x833lz3on) 3 | ```javascript 4 | import { connectSession } from "rxq"; 5 | import { OpenDoc } from "rxq/Global"; 6 | import { CreateSessionObject } from "rxq/Doc"; 7 | import { ApplyPatches, GetLayout, SelectListObjectValues } from "rxq/GenericObject"; 8 | import { filter, map, publish, repeat, shareReplay, startWith, switchMap, withLatestFrom } from "rxjs/operators"; 9 | import { fromEvent } from "rxjs"; 10 | 11 | const appname = "aae16724-dfd9-478b-b401-0d8038793adf" 12 | 13 | // Define the configuration for your session 14 | const config = { 15 | host: "sense.axisgroup.com", 16 | isSecure: true, 17 | appname 18 | }; 19 | 20 | // Connect the session and get the global handle 21 | const session = connectSession(config); 22 | const global$ = session.global$ 23 | 24 | // Open an app and share the app handle 25 | const app$ = global$.pipe( 26 | switchMap(h => h.ask(OpenDoc, appname)), 27 | shareReplay(1) 28 | ); 29 | 30 | // Create a Generic Object with a metric 31 | const obj$ = app$.pipe( 32 | switchMap(h => h.ask(CreateSessionObject, { 33 | "qInfo": { 34 | "qType": "my-object" 35 | }, 36 | "myValue": { 37 | "qValueExpression": "=avg(petal_length)" 38 | } 39 | })), 40 | shareReplay(1) 41 | ); 42 | 43 | // Get the latest selections whenever the model changes 44 | const metricLayouts$ = obj$.pipe( 45 | switchMap(h => h.invalidated$.pipe(startWith(h))), 46 | switchMap(h => h.ask(GetLayout)) 47 | ); 48 | 49 | // Print the selections to the DOM 50 | metricLayouts$.subscribe(layout => { 51 | document.querySelector("#metric").innerHTML = `
    The average petal length is ${layout.myValue}`; 52 | }); 53 | 54 | // Create a Generic Object with a list object for the field "species" 55 | const lb$ = app$.pipe( 56 | switchMap(h => h.ask(CreateSessionObject, { 57 | "qInfo": { 58 | "qType": "my-listbox" 59 | }, 60 | "qListObjectDef": { 61 | "qDef": { 62 | "qFieldDefs": ["species"] 63 | }, 64 | "qInitialDataFetch": [ 65 | { 66 | "qTop": 0, 67 | "qLeft": 0, 68 | "qWidth": 1, 69 | "qHeight": 100 70 | } 71 | ] 72 | } 73 | })), 74 | shareReplay(1) 75 | ); 76 | 77 | // Get a stream of list object layouts 78 | const lbLayouts$ = lb$.pipe( 79 | switchMap(h => h.invalidated$.pipe(startWith(h))), 80 | switchMap(h => h.ask(GetLayout)) 81 | ); 82 | 83 | // Render the list object to the page in an unordered list 84 | lbLayouts$.subscribe(layout => { 85 | const data = layout.qListObject.qDataPages[0].qMatrix; 86 | document.querySelector("ul").innerHTML = data.map(item => `
  • 87 | ${item[0].qText} 88 |
  • `).join(""); 89 | }); 90 | 91 | // Select values when a user clicks on them 92 | const select$ = fromEvent(document.querySelector("body"), "click").pipe( 93 | filter(evt => evt.target.hasAttribute("data-qno")), 94 | map(evt => parseInt(evt.target.getAttribute("data-qno"))), 95 | withLatestFrom(lb$), 96 | switchMap(([qno, h]) => h.ask(SelectListObjectValues, "/qListObjectDef", [qno], true)), 97 | publish() 98 | ); 99 | 100 | select$.connect(); 101 | 102 | // Change the dimension with a dropdown 103 | const patch$ = fromEvent(document.querySelector("select"), "change").pipe( 104 | map(evt => evt.target.value), 105 | withLatestFrom(lb$), 106 | switchMap(([dim, h]) => h.ask(ApplyPatches, [ 107 | { 108 | qPath: "/qListObjectDef/qDef/qFieldDefs/0", 109 | qOp: "replace", 110 | qValue: JSON.stringify(dim) 111 | } 112 | ])), 113 | publish() 114 | ); 115 | 116 | patch$.connect(); 117 | ``` -------------------------------------------------------------------------------- /scripts/build-qix-methods.js: -------------------------------------------------------------------------------- 1 | // with qix version, go through the schema and publish into operators 2 | var fs = require("fs-extra"); 3 | var path = require("path"); 4 | var pack = JSON.parse(fs.readFileSync("package.json", "utf8")); 5 | var Docker = require("dockerode"); 6 | var { Observable } = require("rxjs"); 7 | var { publishReplay, refCount, switchMap } = require("rxjs/operators"); 8 | const http = require("http"); 9 | 10 | // qix version 11 | var version = pack["qix-version"]; 12 | 13 | const image = `qlikcore/engine:${version}`; 14 | const port = "9079"; 15 | 16 | const container$ = createContainer(image, port); 17 | 18 | const schema$ = container$.pipe( 19 | switchMap(container => 20 | Observable.create(observer => { 21 | http 22 | .get(`http://localhost:${port}/jsonrpc-api`, resp => { 23 | let data = ""; 24 | 25 | resp.on("data", chunk => { 26 | data += chunk; 27 | }); 28 | 29 | resp.on("end", () => { 30 | container.kill((err, result) => { 31 | container.remove(); 32 | observer.complete(); 33 | }); 34 | 35 | observer.next(JSON.parse(data)); 36 | }); 37 | }) 38 | .on("error", err => { 39 | observer.error(err); 40 | }); 41 | }) 42 | ) 43 | ); 44 | 45 | schema$.subscribe(schema => { 46 | const schemaOutput = Object.keys(schema.services).reduce((acc, qClass) => { 47 | const classMethods = schema.services[qClass].methods; 48 | return { 49 | ...acc, 50 | [qClass]: Object.keys(classMethods).reduce((methodList, method) => { 51 | const methodResponses = classMethods[method].responses; 52 | return { 53 | ...methodList, 54 | [method]: 55 | typeof methodResponses === "undefined" 56 | ? [] 57 | : methodResponses.map(response => response.name) 58 | }; 59 | }, {}) 60 | }; 61 | }, {}); 62 | 63 | var qClasses = Object.keys(schema.services); 64 | 65 | var classImports = []; 66 | var classExports = []; 67 | 68 | qClasses.forEach(qClass => { 69 | var methods = schema.services[qClass].methods; 70 | 71 | var classDir = `../src/${qClass}`; 72 | var absClassDir = path.join(__dirname, classDir); 73 | fs.emptydirSync(absClassDir); 74 | 75 | const eNumScript = Object.keys(methods) 76 | .reduce((acc, methodname) => { 77 | return [ 78 | `const ${methodname} = "${methodname}"`, 79 | ...acc, 80 | `export { ${methodname} };` 81 | ]; 82 | }, []) 83 | .join("\n"); 84 | 85 | fs.writeFile(path.join(absClassDir, `index.js`), eNumScript); 86 | 87 | classImports.push(`import * as ${qClass} from "./${qClass}";`); 88 | classExports.push(`export { ${qClass} }`); 89 | }); 90 | }); 91 | 92 | function createContainer(image, port) { 93 | // launch a new container 94 | var container$ = Observable.create(observer => { 95 | var docker = new Docker(); 96 | 97 | docker.createContainer( 98 | { 99 | Image: image, 100 | Cmd: ["-S", "AcceptEULA=yes"], 101 | ExposedPorts: { 102 | "9076/tcp": {} 103 | }, 104 | HostConfig: { 105 | RestartPolicy: { 106 | Name: "always" 107 | }, 108 | PortBindings: { 109 | "9076/tcp": [ 110 | { 111 | HostPort: port 112 | } 113 | ] 114 | } 115 | } 116 | }, 117 | (err, container) => { 118 | if (err) return observer.error(err); 119 | 120 | container.start((err, data) => { 121 | if (err) return observer.error(err); 122 | setTimeout(() => { 123 | observer.next(container); 124 | observer.complete(); 125 | }, 2000); 126 | }); 127 | } 128 | ); 129 | }).pipe( 130 | publishReplay(1), 131 | refCount() 132 | ); 133 | 134 | return container$; 135 | } 136 | -------------------------------------------------------------------------------- /scripts/build-browser.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | const path = require("path"); 3 | var StringReplacePlugin = require("string-replace-webpack-plugin"); 4 | var program = require("commander"); 5 | 6 | program 7 | .version("0.1.0") 8 | .option("--min", "minified") 9 | .parse(process.argv); 10 | 11 | const plugins = [new StringReplacePlugin()]; 12 | 13 | const filename = program.min ? "rxq.bundle.min.js" : "rxq.bundle.js"; 14 | 15 | webpack( 16 | { 17 | mode: "production", 18 | entry: path.join(__dirname, "../index.js"), 19 | output: { 20 | path: path.resolve(__dirname, "../dist/bundle"), 21 | filename: filename, 22 | library: "RxQ", 23 | libraryTarget: "umd" 24 | }, 25 | devtool: "source-map", 26 | optimization: { 27 | minimize: program.min || false 28 | }, 29 | module: { 30 | rules: [ 31 | // configure replacements for file patterns 32 | { 33 | enforce: "pre", 34 | test: /src\/|index.js$/, 35 | loader: StringReplacePlugin.replace({ 36 | replacements: [ 37 | { 38 | pattern: /import {([^;]*?)} from ([^;]*?)('|")rxjs([^;]*?)('|");/g, 39 | //pattern: /from ([^;]*?)('|")rxjs([^;]*?)('|");/g, 40 | replacement: function( 41 | match, 42 | p1, 43 | p2, 44 | p3, 45 | p4, 46 | p5, 47 | offset, 48 | string 49 | ) { 50 | return match 51 | .replace("import", "const") 52 | .replace(/from ('|")rxjs('|")/g, "= rxjs") 53 | .replace( 54 | /from ('|")rxjs\/operators('|")/g, 55 | "= rxjs.operators" 56 | ) 57 | .replace(/ as /g, ": "); 58 | 59 | // Check if loading an operator or not 60 | var operatorsFlag = p4.split("/").indexOf("operators") > 0; 61 | 62 | // The source to load from 63 | var srcBase = ["Rx"] 64 | .concat(p4.split("/").filter(f => f != "")) 65 | .join("."); 66 | 67 | // Get vars to map 68 | var defs = p1 69 | .replace(/({|})/g, "") 70 | .split(",") 71 | .map(d => { 72 | var splits = d 73 | .split(" as ") 74 | .map(s => s.replace(/( |\n)/g, "")); 75 | 76 | return { 77 | name: splits[1] || splits[0], 78 | source: splits[0] 79 | }; 80 | }); 81 | 82 | // Turn the var definitions into statements 83 | var globalImports = defs 84 | .map(d => { 85 | var dec = `var ${d.name} = ${srcBase}`; 86 | if (operatorsFlag) dec += `.${d.source}`; 87 | dec += ";"; 88 | return dec; 89 | }) 90 | .join("\n") 91 | .replace(/observable/g, "Observable"); 92 | 93 | return globalImports; 94 | } 95 | } 96 | ] 97 | }) 98 | }, 99 | // Babel 100 | { 101 | test: /\.js$/, 102 | exclude: /node_modules/, 103 | loader: "babel-loader", 104 | options: { 105 | presets: ["es2015"], 106 | plugins: [ 107 | // "transform-runtime", 108 | "transform-object-rest-spread", 109 | "babel-plugin-add-module-exports" 110 | ] 111 | } 112 | } 113 | ] 114 | }, 115 | plugins: plugins 116 | }, 117 | (err, stats) => { 118 | if (err || stats.hasErrors()) { 119 | console.log(err); 120 | // Handle errors here 121 | } 122 | console.log(stats); 123 | // Done processing 124 | } 125 | ); 126 | -------------------------------------------------------------------------------- /docs/basics/shortcut-operators.md: -------------------------------------------------------------------------------- 1 | # Shortcut Operators for Common Patterns 2 | 3 | ## Common Patterns 4 | In the [Making API Calls](making-api-calls.html) section, we discuss using methods like `mergeMap`, `switchMap`, and `concatMap` along with a Handle's `ask` method to turn an Observable of a Handle into an Observable of an API call. For example, to get the engine version from a global handle, you would do the following: 5 | ```javascript 6 | // Assuming an Observable of a Global Handle 7 | const global$; 8 | 9 | const engineVersion$ = global$.pipe( 10 | switchMap(handle => handle.ask(EngineVersion)) 11 | ); 12 | ``` 13 | 14 | When working with Handle Observables, you often want to reuse them to make API calls. In this scenario, it is common to [share the handle observable via multicasting](reusing-handles.html): 15 | ```javascript 16 | const app$ = global$.pipe( 17 | switchMap(handle => handle.ask(OpenDoc, "my-app.qvf")), 18 | shareReplay(1) 19 | ); 20 | ``` 21 | 22 | Similarly, you can get a stream of [handle invalidations](handle-invalidations.html) using the following pattern: 23 | ```javascript 24 | // Assume an Observable of a Doc Handle 25 | const app$; 26 | 27 | const appInvalidations$ = app$.pipe( 28 | switchMap(handle => handle.invalidated$) 29 | ); 30 | ``` 31 | 32 | ## Simplifying these Patterns 33 | Manually using operators like `switchMap`, `mergeMap`, and `concatMap` can be useful when writing more complex async logic with the Qlik Engine. However, in our experience the vast majority of API calls with RxQ follow the patterns above. It can get tedious rewriting these patterns over and over, so we have encapsulated the logic into 3 helper operators: 34 | 35 | ### qAsk 36 | `qAsk` is an alias for `switchMap(handle => handle.ask())`. For example, the following patterns are equivalent: 37 | 38 | *EngineVersion using original pattern* 39 | ```javascript 40 | const engineVersion$ = global$.pipe( 41 | switchMap(handle => handle.ask(EngineVersion)) 42 | ); 43 | ``` 44 | 45 | *EngineVersion using qAsk* 46 | ```javascript 47 | import { qAsk } from "rxq"; 48 | 49 | const engineVersion$ = global$.pipe( 50 | qAsk(EngineVersion) 51 | ); 52 | ``` 53 | 54 | ### qAskReplay 55 | `qAskReplay` is an alias for `switchMap(handle => handle.ask()), shareReplay(1)`. For example, the following patterns are equivalent: 56 | 57 | *App Handle using original pattern* 58 | ```javascript 59 | const doc$ = global$.pipe( 60 | switchMap(handle => handle.ask(OpenDoc, "my-app.qvf")), 61 | shareReplay(1) 62 | ); 63 | ``` 64 | 65 | *App Handle using qAskReplay* 66 | ```javascript 67 | import { qAskReplay } from "rxq"; 68 | 69 | const doc$$ = global$.pipe( 70 | qAskReplay(OpenDoc, "my-app.qvf") 71 | ); 72 | ``` 73 | 74 | ### invalidations 75 | `invalidations` is an alias for `switchMap(handle => handle.invalidated$)`. For example, the following patterns are equivalent: 76 | 77 | *App Handle invalidations using original pattern* 78 | ```javascript 79 | const docInvalidations$ = doc$.pipe( 80 | switchMap(handle => handle.invalidated$) 81 | ); 82 | ``` 83 | 84 | *App Handle invalidations using `invalidations`* 85 | ```javascript 86 | import { invalidations } from "rxq"; 87 | 88 | const docInvalidations$ = doc$.pipe( 89 | invalidations() 90 | ); 91 | ``` 92 | 93 | In many cases, it's useful to assume an initial invalidation for a Qlik Handle when working with it. Automatically starting with an invalidation can be accomplished by calling `qInvalidations(true)`: 94 | 95 | *App Handle invalidations with initial handle emit* 96 | ```javascript 97 | import { invalidations } from "rxq"; 98 | 99 | const docInvalidations$ = doc$.pipe( 100 | invalidations(true) 101 | ); 102 | ``` 103 | 104 | Putting these together, you can greatly simplify some common scenarios. For example, consider getting a stream of layouts for a generic object: 105 | 106 | *GenericObject layouts via original pattern* 107 | ```javascript 108 | const layouts$ = object$.pipe( 109 | switchMap(handle => handle.invalidated$.pipe( 110 | startWith(handle) 111 | ) 112 | ), 113 | switchMap(handle => handle.ask(GetLayout)) 114 | ); 115 | ``` 116 | 117 | *GenericObject layouts using shortcut operators* 118 | ```javascript 119 | const layouts$ = object$.pipe( 120 | invalidations(true), 121 | qAsk(GetLayout) 122 | ); 123 | ``` -------------------------------------------------------------------------------- /test/unit/Session.spec.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var { Observable, Subject } = require("rxjs"); 6 | var { pluck, take } = require("rxjs/operators"); 7 | const isObservable = require("../util/isObservable"); 8 | const mockEngine = require("../util/mock-qix-engine.js"); 9 | 10 | // RxQ 11 | var { connectSession } = require("../../dist"); 12 | var Handle = require("../../dist/_cjs/handle"); 13 | var Session = require("../../dist/_cjs/session"); 14 | 15 | describe("Session", function() { 16 | // Mock Engine for Testing 17 | var { server, ws } = mockEngine(); 18 | var config = { 19 | ws 20 | }; 21 | 22 | const session = connectSession(config); 23 | var eng$ = session.global$; 24 | 25 | var sesh$ = eng$.pipe(pluck("session")); 26 | 27 | it("should be a function", function() { 28 | expect(Session).to.be.a("function"); 29 | }); 30 | 31 | it("should have an ask method", function(done) { 32 | sesh$.subscribe(sesh => { 33 | expect(sesh.ask).to.be.a("function"); 34 | done(); 35 | }); 36 | }); 37 | 38 | it("should have a global method", function(done) { 39 | sesh$.subscribe(sesh => { 40 | expect(sesh.global).to.be.a("function"); 41 | done(); 42 | }); 43 | }); 44 | 45 | it("should have a WebSocket Observable", function(done) { 46 | sesh$.subscribe(sesh => { 47 | expect(isObservable(sesh.ws$)).to.equal(true); 48 | // expect(sesh.ws$).to.be.instanceof(Observable); 49 | done(); 50 | }); 51 | }); 52 | 53 | it("should have a response Observable", function(done) { 54 | sesh$.subscribe(sesh => { 55 | expect(isObservable(sesh.finalResponse$)).to.equal(true); 56 | done(); 57 | }); 58 | }); 59 | 60 | it("should have a changes Observable", function(done) { 61 | sesh$.subscribe(sesh => { 62 | expect(isObservable(sesh.changes$)).to.equal(true); 63 | done(); 64 | }); 65 | }); 66 | 67 | it("should have a sequence integer generator", function(done) { 68 | sesh$.subscribe(sesh => { 69 | expect(sesh).to.have.property("seqGen"); 70 | done(); 71 | }); 72 | }); 73 | 74 | describe("ask method", function() { 75 | it("should return an Observable", function(done) { 76 | sesh$.subscribe(sesh => { 77 | var req = sesh.ask({}); 78 | expect(isObservable(req)).to.equal(true); 79 | done(); 80 | }); 81 | }); 82 | 83 | it("should dispatch a method to the request stream", function(done) { 84 | sesh$.subscribe(sesh => { 85 | var methodName = "myMethod"; 86 | 87 | sesh.requests$.pipe(take(1)).subscribe(r => { 88 | var method = r.method; 89 | expect(method).to.equal(methodName); 90 | done(); 91 | }); 92 | 93 | var req = sesh.ask({ method: "myMethod" }); 94 | }); 95 | }); 96 | 97 | it("should dispatch method parameters to the request stream", function(done) { 98 | sesh$.subscribe(sesh => { 99 | var methodParams = ["hello", 42, true]; 100 | 101 | sesh.requests$.pipe(take(1)).subscribe(r => { 102 | var params = r.params; 103 | expect(params).to.equal(methodParams); 104 | done(); 105 | }); 106 | 107 | var req = sesh.ask({ params: methodParams }); 108 | }); 109 | }); 110 | 111 | it("should give requests a numeric id", function(done) { 112 | sesh$.subscribe(sesh => { 113 | sesh.requests$.pipe(take(1)).subscribe(r => { 114 | var id = r.id; 115 | expect(id).to.be.a("number"); 116 | done(); 117 | }); 118 | 119 | var req = sesh.ask({}); 120 | }); 121 | }); 122 | }); 123 | 124 | describe("global method", function() { 125 | it("should return an Observable", function(done) { 126 | sesh$.subscribe(sesh => { 127 | var global$ = sesh.global(); 128 | expect(isObservable(global$)).to.equal(true); 129 | done(); 130 | }); 131 | }); 132 | }); 133 | 134 | describe("Sequence Generator", function() { 135 | it("should yield a value", function(done) { 136 | sesh$.subscribe(sesh => { 137 | var gen = sesh.seqGen; 138 | expect(gen).to.yield(); 139 | done(); 140 | }); 141 | }); 142 | 143 | it("should increment values by 1", function(done) { 144 | sesh$.subscribe(sesh => { 145 | var gen = sesh.seqGen; 146 | var firstValue = gen.next().value; 147 | var secondValue = gen.next().value; 148 | expect(secondValue).to.equal(firstValue + 1); 149 | done(); 150 | }); 151 | }); 152 | }); 153 | 154 | after(function() { 155 | server.stop(); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /test/unit/Handle.spec.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var { Observable, Subject } = require("rxjs"); 6 | var { pluck, take, shareReplay } = require("rxjs/operators"); 7 | const mockEngine = require("../util/mock-qix-engine.js"); 8 | const isObservable = require("../util/isObservable"); 9 | 10 | // RxQ 11 | var { connectSession } = require("../../dist"); 12 | var Handle = require("../../dist/_cjs/handle"); 13 | var Session = require("../../dist/_cjs/session"); 14 | 15 | describe("Handle", function() { 16 | // Mock Engine for Testing 17 | var { server, ws } = mockEngine(); 18 | var config = { 19 | ws 20 | }; 21 | 22 | const session = connectSession(config); 23 | const notifications$ = session.notifications$; 24 | var eng$ = session.global$; 25 | 26 | it("should have a handle number property", function(done) { 27 | eng$.subscribe(h => { 28 | expect(h.handle).to.be.a("number"); 29 | done(); 30 | }); 31 | }); 32 | 33 | it("should have an invalidated Observable stream", function(done) { 34 | eng$.subscribe(h => { 35 | expect(isObservable(h.invalidated$)).to.equal(true); 36 | done(); 37 | }); 38 | }); 39 | 40 | it("should have a qClass string property", function(done) { 41 | eng$.subscribe(h => { 42 | expect(h.qClass).to.be.a("string"); 43 | done(); 44 | }); 45 | }); 46 | 47 | it("should have a Session property", function(done) { 48 | eng$.subscribe(h => { 49 | expect(h.session).to.be.instanceof(Session); 50 | done(); 51 | }); 52 | }); 53 | 54 | it("should have an ask method", function(done) { 55 | eng$.subscribe(h => { 56 | expect(h.ask).to.be.a("function"); 57 | done(); 58 | }); 59 | }); 60 | 61 | describe("ask method", function() { 62 | it("should return an Observable", function(done) { 63 | eng$.subscribe(h => { 64 | var req = h.ask("t"); 65 | expect(isObservable(req)).to.equal(true); 66 | done(); 67 | }); 68 | }); 69 | 70 | it("should dispatch a method to the request stream", function(done) { 71 | eng$.subscribe(handle => { 72 | var sesh = handle.session; 73 | 74 | var methodName = "myMethod"; 75 | 76 | sesh.requests$.pipe(take(1)).subscribe(r => { 77 | var method = r.method; 78 | expect(method).to.equal(methodName); 79 | done(); 80 | }); 81 | 82 | var req = handle.ask("myMethod").subscribe(); 83 | }); 84 | }); 85 | 86 | it("should dispatch method parameters to the request stream", function(done) { 87 | eng$.subscribe(handle => { 88 | var sesh = handle.session; 89 | 90 | var methodParams = ["hello", 42, true]; 91 | 92 | sesh.requests$.pipe(take(1)).subscribe(r => { 93 | var params = r.params; 94 | expect(params).to.deep.equal(methodParams); 95 | done(); 96 | }); 97 | 98 | var req = handle 99 | .ask("methodParamsTest - " + sesh.sessionId, ...methodParams) 100 | .subscribe(); 101 | }); 102 | }); 103 | 104 | it("should filter out undefined parameters", function(done) { 105 | eng$.subscribe(handle => { 106 | var sesh = handle.session; 107 | 108 | var methodParams = ["hello", 42, undefined]; 109 | var expectedParams = ["hello", 42]; 110 | 111 | sesh.requests$.pipe(take(1)).subscribe(r => { 112 | var params = r.params; 113 | expect(params).to.deep.equal(expectedParams); 114 | done(); 115 | }); 116 | 117 | var req = handle 118 | .ask("methodParamsTest - " + sesh.sessionId, ...methodParams) 119 | .subscribe(); 120 | }); 121 | }); 122 | 123 | it("should give requests a numeric id", function(done) { 124 | eng$.subscribe(handle => { 125 | var sesh = handle.session; 126 | 127 | sesh.requests$.pipe(take(1)).subscribe(r => { 128 | var id = r.id; 129 | expect(id).to.be.a("number"); 130 | done(); 131 | }); 132 | 133 | var req = handle.ask("numericId - " + sesh.sessionId).subscribe(); 134 | }); 135 | }); 136 | 137 | it("should generate requests for its handle number", function(done) { 138 | eng$.subscribe(handle => { 139 | var sesh = handle.session; 140 | var no = handle.handle; 141 | 142 | sesh.requests$.pipe(take(1)).subscribe(r => { 143 | var h = r.handle; 144 | expect(h).to.equal(no); 145 | done(); 146 | }); 147 | 148 | var req = handle.ask("handleNumber - " + sesh.sessionId).subscribe(); 149 | }); 150 | }); 151 | }); 152 | 153 | after(function() { 154 | server.stop(); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /test/e2e/suspend.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | map, 8 | publish, 9 | publishReplay, 10 | refCount, 11 | shareReplay, 12 | switchMap, 13 | take, 14 | takeUntil, 15 | withLatestFrom, 16 | tap 17 | } = require("rxjs/operators"); 18 | var { Subject } = require("rxjs"); 19 | 20 | var { OpenDoc } = require("../../dist/global"); 21 | var { connectSession, suspendUntilCompleted } = require("../../dist"); 22 | var Handle = require("../../dist/_cjs/handle"); 23 | 24 | var { GetAppProperties, SetAppProperties } = require("../../dist/doc"); 25 | 26 | var { port, image } = require("./config.json"); 27 | 28 | // launch a new container 29 | var container$ = createContainer(image, port); 30 | 31 | const session$ = container$.pipe( 32 | map(() => { 33 | return connectSession({ 34 | host: "localhost", 35 | port: port, 36 | isSecure: false 37 | }); 38 | }), 39 | shareReplay(1) 40 | ); 41 | 42 | var eng$ = session$.pipe( 43 | switchMap(session => session.global$), 44 | shareReplay(1) 45 | ); 46 | 47 | const app$ = eng$.pipe( 48 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 49 | publishReplay(1), 50 | refCount() 51 | ); 52 | 53 | function testSuspend() { 54 | describe("Suspend", function() { 55 | before(function(done) { 56 | this.timeout(10000); 57 | app$.subscribe(() => done()); 58 | }); 59 | 60 | it("should withhold invalidations while suspended", function(done) { 61 | this.timeout(5000); 62 | 63 | // Trigger invalidation event by changing app events 64 | const setAppProps$ = app$.pipe( 65 | withLatestFrom(session$, (appH, session) => { 66 | session.suspend(); 67 | return appH; 68 | }), 69 | switchMap(handle => handle.ask(GetAppProperties)), 70 | take(1), 71 | withLatestFrom(app$), 72 | switchMap(([props, handle]) => { 73 | const newProps = Object.assign({ test: "invalid" }, props); 74 | return handle.ask(SetAppProperties, newProps); 75 | }), 76 | publish() 77 | ); 78 | 79 | const invalid$ = app$.pipe(switchMap(h => h.invalidated$)); 80 | 81 | var streamKill$ = new Subject(); 82 | 83 | invalid$.pipe(takeUntil(streamKill$)).subscribe(h => { 84 | done(new Error("Invalidation fired")); 85 | }); 86 | 87 | setTimeout(() => { 88 | streamKill$.next(undefined); 89 | done(); 90 | }, 2000); 91 | 92 | setAppProps$.connect(); 93 | }); 94 | 95 | it("should share buffered invalidations when unsuspended", function(done) { 96 | const invalid$ = app$.pipe(switchMap(h => h.invalidated$)); 97 | 98 | invalid$.subscribe(() => { 99 | done(); 100 | }); 101 | 102 | session$.subscribe(session => { 103 | session.unsuspend(); 104 | }); 105 | }); 106 | 107 | describe("suspendUntilCompleted operator", function() { 108 | it("should buffer invalidations until the Observable completes", function(done) { 109 | this.timeout(10000); 110 | 111 | const session$ = container$.pipe( 112 | map(() => { 113 | return connectSession({ 114 | host: "localhost", 115 | port: port, 116 | isSecure: false, 117 | appname: "iris.qvf" 118 | }); 119 | }), 120 | shareReplay(1) 121 | ); 122 | 123 | var eng$ = session$.pipe( 124 | switchMap(session => session.global$), 125 | publishReplay(1), 126 | refCount() 127 | ); 128 | 129 | const app$ = eng$.pipe( 130 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 131 | publishReplay(1), 132 | refCount() 133 | ); 134 | 135 | // Trigger invalidation event by changing app events 136 | const setAppProps$ = app$.pipe( 137 | withLatestFrom(session$), 138 | switchMap(([appHandle, session]) => 139 | appHandle.ask(GetAppProperties).pipe( 140 | switchMap(props => { 141 | const newProps = Object.assign({ test: "invalid" }, props); 142 | return appHandle.ask(SetAppProperties, newProps); 143 | }), 144 | suspendUntilCompleted(session) 145 | ) 146 | ), 147 | publish() 148 | ); 149 | 150 | const invalid$ = app$.pipe(switchMap(h => h.invalidated$)); 151 | 152 | invalid$.subscribe(h => { 153 | done(); 154 | }); 155 | 156 | setAppProps$.connect(); 157 | }); 158 | }); 159 | 160 | after(function(done) { 161 | container$.subscribe(container => 162 | container.kill((err, result) => { 163 | container.remove(); 164 | done(); 165 | }) 166 | ); 167 | }); 168 | }); 169 | } 170 | 171 | module.exports = testSuspend; 172 | -------------------------------------------------------------------------------- /test/e2e/closeSession.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | publishReplay, 8 | refCount, 9 | switchMap, 10 | map, 11 | filter, 12 | shareReplay 13 | } = require("rxjs/operators"); 14 | var { connectSession } = require("../../dist"); 15 | var Handle = require("../../dist/_cjs/handle"); 16 | var { OpenDoc } = require("../../dist/_cjs/Global"); 17 | 18 | var { Observable } = require("rxjs"); 19 | 20 | var { port, image } = require("./config.json"); 21 | 22 | // launch a new container 23 | var container$ = createContainer(image, port); 24 | 25 | var session$ = container$.pipe( 26 | map(() => { 27 | return connectSession({ 28 | host: "localhost", 29 | port: port, 30 | isSecure: false 31 | }); 32 | }), 33 | publishReplay(1), 34 | refCount() 35 | ); 36 | 37 | const eng$ = session$.pipe( 38 | switchMap(session => session.global$), 39 | shareReplay(1) 40 | ); 41 | const notifications$ = session$.pipe( 42 | switchMap(session => session.notifications$) 43 | ); 44 | const ws$ = eng$.pipe(switchMap(eng => eng.session.ws$)); 45 | const socketClosed$ = ws$.pipe( 46 | switchMap(ws => 47 | Observable.create(observer => { 48 | ws.addEventListener("close", evt => { 49 | observer.next(evt); 50 | }); 51 | }) 52 | ) 53 | ); 54 | 55 | const app$ = eng$.pipe( 56 | switchMap(h => h.ask(OpenDoc, "iris.qvf")), 57 | publishReplay(1) 58 | ); 59 | 60 | function testClose() { 61 | describe("Close", function() { 62 | before(function(done) { 63 | this.timeout(10000); 64 | container$.subscribe(() => done()); 65 | // connect the app 66 | app$.connect(); 67 | }); 68 | 69 | it("should close the WebSocket when the close function is called", function(done) { 70 | socketClosed$.subscribe(closedEvt => { 71 | done(); 72 | }); 73 | 74 | // Get the engine handle and then close 75 | app$.pipe(switchMap(() => session$)).subscribe(session => { 76 | session.close(); 77 | }); 78 | }); 79 | 80 | it("should complete invalidation Observables when the close function is called", function(done) { 81 | app$.pipe(switchMap(handle => handle.invalidated$)).subscribe({ 82 | complete: () => done() 83 | }); 84 | }); 85 | 86 | it("should trigger the 'socket:close' notification", done => { 87 | const session$ = container$.pipe( 88 | map(() => { 89 | return connectSession({ 90 | host: "localhost", 91 | port: port, 92 | isSecure: false 93 | }); 94 | }), 95 | publishReplay(1), 96 | refCount() 97 | ); 98 | 99 | const eng$ = session$.pipe( 100 | switchMap(session => session.global$), 101 | shareReplay(1) 102 | ); 103 | const notifications$ = session$.pipe( 104 | switchMap(session => session.notifications$) 105 | ); 106 | 107 | notifications$ 108 | .pipe(filter(f => f.type === "socket:close")) 109 | .subscribe(() => done()); 110 | 111 | eng$ 112 | .pipe(switchMap(() => session$)) 113 | .subscribe(session => session.close()); 114 | }); 115 | 116 | it("should complete the responses stream", done => { 117 | const session$ = container$.pipe( 118 | map(() => { 119 | return connectSession({ 120 | host: "localhost", 121 | port: port, 122 | isSecure: false 123 | }); 124 | }), 125 | publishReplay(1), 126 | refCount() 127 | ); 128 | 129 | const eng$ = session$.pipe( 130 | switchMap(session => session.global$), 131 | shareReplay(1) 132 | ); 133 | const notifications$ = session$.pipe( 134 | switchMap(session => session.notifications$) 135 | ); 136 | 137 | eng$.pipe(switchMap(handle => handle.session.finalResponse$)).subscribe({ 138 | complete: () => done() 139 | }); 140 | 141 | eng$ 142 | .pipe(switchMap(() => session$)) 143 | .subscribe(session => session.close()); 144 | }); 145 | 146 | it("should complete the requests stream", done => { 147 | const session$ = container$.pipe( 148 | map(() => { 149 | return connectSession({ 150 | host: "localhost", 151 | port: port, 152 | isSecure: false 153 | }); 154 | }), 155 | publishReplay(1), 156 | refCount() 157 | ); 158 | 159 | const eng$ = session$.pipe( 160 | switchMap(session => session.global$), 161 | shareReplay(1) 162 | ); 163 | const notifications$ = session$.pipe( 164 | switchMap(session => session.notifications$) 165 | ); 166 | 167 | eng$.pipe(switchMap(handle => handle.session.requests$)).subscribe({ 168 | complete: () => done() 169 | }); 170 | 171 | eng$ 172 | .pipe(switchMap(() => session$)) 173 | .subscribe(session => session.close()); 174 | }); 175 | 176 | after(function(done) { 177 | container$.subscribe(container => 178 | container.kill((err, result) => { 179 | container.remove(); 180 | done(); 181 | }) 182 | ); 183 | }); 184 | }); 185 | } 186 | 187 | module.exports = testClose; 188 | -------------------------------------------------------------------------------- /test/e2e/utilityOperators.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | const spies = require("chai-spies"); 3 | chai.use(require("chai-generator")); 4 | chai.use(spies); 5 | const expect = chai.expect; 6 | 7 | var createContainer = require("../util/create-container"); 8 | var { 9 | shareReplay, 10 | map, 11 | switchMap, 12 | tap, 13 | publish, 14 | take 15 | } = require("rxjs/operators"); 16 | const { of } = require("rxjs"); 17 | 18 | var { OpenDoc } = require("../../dist/global"); 19 | var { CreateSessionObject, GetAppProperties } = require("../../dist/doc"); 20 | var { SetProperties } = require("../../dist/GenericObject"); 21 | var { connectSession, qAsk, qAskReplay, invalidations } = require("../../dist"); 22 | 23 | var { port, image } = require("./config.json"); 24 | 25 | // launch a new container 26 | var container$ = createContainer(image, port); 27 | 28 | var session$ = container$.pipe( 29 | map(() => { 30 | return connectSession({ 31 | host: "localhost", 32 | port: port, 33 | isSecure: false 34 | }); 35 | }), 36 | shareReplay(1) 37 | ); 38 | 39 | var eng$ = session$.pipe(switchMap(session => session.global$)); 40 | 41 | const app$ = eng$.pipe( 42 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 43 | shareReplay(1) 44 | ); 45 | 46 | function testUtilityOperators() { 47 | describe("Utility Operators", function() { 48 | before(function(done) { 49 | this.timeout(10000); 50 | app$.subscribe(() => done()); 51 | }); 52 | 53 | describe("qAsk", () => { 54 | it("should make an Engine API call and deliver the response", done => { 55 | app$.pipe(qAsk(GetAppProperties)).subscribe(props => { 56 | expect(props.qTitle).to.equal("Iris"); 57 | done(); 58 | }); 59 | }); 60 | 61 | it("should throw an error when called on an Observable that doesn't emit a Handle", done => { 62 | of(null) 63 | .pipe(qAsk(GetAppProperties)) 64 | .subscribe({ 65 | error: err => { 66 | done(); 67 | } 68 | }); 69 | }); 70 | }); 71 | 72 | describe("qAskReplay", () => { 73 | it("should make an Engine API call and share the response across subscribers", done => { 74 | var count = 0; 75 | 76 | const appProps$ = app$.pipe( 77 | tap(() => count++), 78 | qAskReplay(GetAppProperties) 79 | ); 80 | 81 | appProps$.subscribe(props => { 82 | appProps$.subscribe(props => { 83 | expect(props.qTitle).to.equal("Iris"); 84 | expect(count).to.equal(1); 85 | done(); 86 | }); 87 | }); 88 | }); 89 | 90 | it("should throw an error when called on an Observable that doesn't emit a Handle", done => { 91 | of(null) 92 | .pipe(qAskReplay(GetAppProperties)) 93 | .subscribe({ 94 | error: err => { 95 | done(); 96 | } 97 | }); 98 | }); 99 | }); 100 | 101 | describe("invalidations", () => { 102 | it("should not start with an invalidation by default", done => { 103 | const obj$ = app$.pipe( 104 | qAskReplay(CreateSessionObject, { 105 | qInfo: { qType: "test" }, 106 | foo: "bar" 107 | }) 108 | ); 109 | 110 | const testFn = chai.spy(); 111 | const invalidations$ = obj$.pipe( 112 | invalidations(), 113 | tap(testFn), 114 | publish() 115 | ); 116 | 117 | invalidations$.connect(); 118 | 119 | // update app props 120 | const setProps$ = obj$.pipe( 121 | qAsk(SetProperties, { 122 | qInfo: { qType: "test" }, 123 | foo: "baz" 124 | }), 125 | take(1) 126 | ); 127 | 128 | setProps$.subscribe({ 129 | complete: () => { 130 | expect(testFn).to.have.been.called.once; 131 | done(); 132 | } 133 | }); 134 | }); 135 | 136 | it("should start with an invalidation when passed true", done => { 137 | const obj$ = app$.pipe( 138 | qAskReplay(CreateSessionObject, { 139 | qInfo: { qType: "test" }, 140 | foo: "bar" 141 | }) 142 | ); 143 | 144 | const testFn = chai.spy(); 145 | const invalidations$ = obj$.pipe( 146 | invalidations(true), 147 | tap(testFn), 148 | publish() 149 | ); 150 | 151 | invalidations$.connect(); 152 | 153 | // update app props 154 | const setProps$ = obj$.pipe( 155 | qAsk(SetProperties, { 156 | qInfo: { qType: "test" }, 157 | foo: "baz" 158 | }), 159 | take(1) 160 | ); 161 | 162 | setProps$.subscribe({ 163 | complete: () => { 164 | expect(testFn).to.have.been.called.twice; 165 | done(); 166 | } 167 | }); 168 | }); 169 | 170 | it("should throw an error when called on an Observable that doesn't emit a Handle", done => { 171 | of(null) 172 | .pipe(invalidations()) 173 | .subscribe({ 174 | error: err => { 175 | done(); 176 | } 177 | }); 178 | }); 179 | }); 180 | 181 | after(function(done) { 182 | container$.subscribe(container => 183 | container.kill((err, result) => { 184 | container.remove(); 185 | done(); 186 | }) 187 | ); 188 | }); 189 | }); 190 | } 191 | 192 | module.exports = testUtilityOperators; 193 | -------------------------------------------------------------------------------- /test/e2e/genericObject.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | publish, 8 | publishReplay, 9 | refCount, 10 | shareReplay, 11 | switchMap, 12 | take, 13 | tap, 14 | withLatestFrom 15 | } = require("rxjs/operators"); 16 | 17 | var { OpenDoc } = require("../../dist/global"); 18 | var { connectSession } = require("../../dist"); 19 | var Handle = require("../../dist/_cjs/handle"); 20 | 21 | var { CreateSessionObject, GetObject } = require("../../dist/doc"); 22 | var { 23 | ApplyPatches, 24 | GetLayout, 25 | GetProperties, 26 | SetProperties 27 | } = require("../../dist/genericObject"); 28 | 29 | var { port, image } = require("./config.json"); 30 | 31 | // launch a new container 32 | var container$ = createContainer(image, port); 33 | 34 | var eng$ = container$.pipe( 35 | switchMap(() => { 36 | return connectSession({ 37 | host: "localhost", 38 | port: port, 39 | isSecure: false 40 | }).global$; 41 | }), 42 | publishReplay(1), 43 | refCount() 44 | ); 45 | 46 | const app$ = eng$.pipe( 47 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 48 | publishReplay(1), 49 | refCount() 50 | ); 51 | 52 | const obj$ = app$.pipe( 53 | switchMap(handle => 54 | handle.ask(CreateSessionObject, { 55 | qInfo: { 56 | qType: "test-e2e" 57 | }, 58 | metric: { 59 | qValueExpression: "=1+1" 60 | }, 61 | foo: "bar" 62 | }) 63 | ), 64 | shareReplay(1) 65 | ); 66 | 67 | function testGenericObject() { 68 | describe("GenericObject Class", function() { 69 | before(function(done) { 70 | this.timeout(10000); 71 | container$.subscribe(() => done()); 72 | }); 73 | 74 | describe("GetProperties", function() { 75 | const objProps$ = obj$.pipe( 76 | switchMap(h => h.ask(GetProperties)), 77 | shareReplay(1) 78 | ); 79 | 80 | it("should return an object", function(done) { 81 | objProps$.subscribe(props => { 82 | expect(props).to.be.a("object"); 83 | done(); 84 | }); 85 | }); 86 | 87 | it("should have a property called 'qInfo' that is an object", function(done) { 88 | objProps$.subscribe(props => { 89 | expect(props.qInfo).to.be.a("object"); 90 | done(); 91 | }); 92 | }); 93 | 94 | it("should have a property called 'foo' that equals 'bar'", function(done) { 95 | objProps$.subscribe(props => { 96 | expect(props.foo).to.equal("bar"); 97 | done(); 98 | }); 99 | }); 100 | }); 101 | 102 | describe("SetProperties", function() { 103 | const objProps$ = obj$.pipe( 104 | switchMap(h => h.ask(GetProperties)), 105 | shareReplay(1) 106 | ); 107 | 108 | const updatedObjProps$ = obj$.pipe( 109 | switchMap(h => 110 | h.ask(SetProperties, { 111 | qInfo: { 112 | qType: "test-e2e" 113 | }, 114 | metric: { 115 | qValueExpression: "=1+1" 116 | }, 117 | foo: "baz" 118 | }) 119 | ), 120 | switchMap(() => objProps$) 121 | ); 122 | 123 | it("should set the property 'foo' to equal 'baz'", function(done) { 124 | updatedObjProps$.subscribe(props => { 125 | expect(props.foo).to.equal("baz"); 126 | done(); 127 | }); 128 | }); 129 | }); 130 | 131 | describe("GetLayout", function() { 132 | const layout$ = obj$.pipe( 133 | switchMap(h => h.ask(GetLayout)), 134 | shareReplay(1) 135 | ); 136 | 137 | it("should return an object", function(done) { 138 | layout$.subscribe(layout => { 139 | expect(layout).to.be.a("object"); 140 | done(); 141 | }); 142 | }); 143 | 144 | it("should have a property 'metric' that equals 2", function(done) { 145 | layout$.subscribe(layout => { 146 | expect(layout.metric).to.equal(2); 147 | done(); 148 | }); 149 | }); 150 | }); 151 | 152 | describe("ApplyPatches", function() { 153 | const patches$ = obj$.pipe( 154 | switchMap(h => 155 | h.ask(ApplyPatches, [ 156 | { 157 | qOp: "add", 158 | qPath: "/patch", 159 | qValue: "1" 160 | } 161 | ]) 162 | ), 163 | publish() 164 | ); 165 | 166 | it("should cause an invalidation", function(done) { 167 | obj$ 168 | .pipe( 169 | switchMap(h => h.invalidated$), 170 | take(1) 171 | ) 172 | .subscribe(i => { 173 | done(); 174 | }); 175 | 176 | patches$.connect(); 177 | }); 178 | 179 | it("should create a new property called 'patch' with a value of 1", function(done) { 180 | obj$ 181 | .pipe( 182 | switchMap(h => h.invalidated$), 183 | switchMap(h => h.ask(GetLayout)), 184 | take(1) 185 | ) 186 | .subscribe(layout => { 187 | expect(layout.patch).to.equal(1); 188 | done(); 189 | }); 190 | 191 | patches$.connect(); 192 | }); 193 | }); 194 | 195 | after(function(done) { 196 | container$.subscribe(container => 197 | container.kill((err, result) => { 198 | container.remove(); 199 | done(); 200 | }) 201 | ); 202 | }); 203 | }); 204 | } 205 | 206 | module.exports = testGenericObject; 207 | -------------------------------------------------------------------------------- /test/e2e/doc.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | publishReplay, 8 | refCount, 9 | shareReplay, 10 | switchMap, 11 | take, 12 | withLatestFrom 13 | } = require("rxjs/operators"); 14 | 15 | var { OpenDoc } = require("../../dist/global"); 16 | var { connectSession } = require("../../dist"); 17 | var Handle = require("../../dist/_cjs/handle"); 18 | 19 | var { 20 | CreateObject, 21 | CreateSessionObject, 22 | GetAppProperties, 23 | GetObject, 24 | SetAppProperties 25 | } = require("../../dist/doc"); 26 | 27 | var { port, image } = require("./config.json"); 28 | 29 | // launch a new container 30 | var container$ = createContainer(image, port); 31 | 32 | var eng$ = container$.pipe( 33 | switchMap(() => { 34 | return connectSession({ 35 | host: "localhost", 36 | port: port, 37 | isSecure: false 38 | }).global$; 39 | }), 40 | publishReplay(1), 41 | refCount() 42 | ); 43 | 44 | const app$ = eng$.pipe( 45 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 46 | publishReplay(1), 47 | refCount() 48 | ); 49 | 50 | function testDoc() { 51 | describe("Doc Class", function() { 52 | before(function(done) { 53 | this.timeout(10000); 54 | container$.subscribe(() => done()); 55 | }); 56 | 57 | describe("GetAppProperties", function() { 58 | const appProps$ = app$.pipe( 59 | switchMap(handle => handle.ask(GetAppProperties)), 60 | publishReplay(1), 61 | refCount() 62 | ); 63 | 64 | it("should return an object", function(done) { 65 | appProps$.subscribe(props => { 66 | expect(props).to.be.a("object"); 67 | done(); 68 | }); 69 | }); 70 | 71 | it("should have a property 'qTitle' that equals 'Iris'", function(done) { 72 | appProps$.subscribe(props => { 73 | expect(props.qTitle).to.equal("Iris"); 74 | done(); 75 | }); 76 | }); 77 | }); 78 | 79 | describe("setAppProperties", function() { 80 | const appProps$ = app$.pipe( 81 | switchMap(handle => handle.ask(GetAppProperties)), 82 | publishReplay(1), 83 | refCount() 84 | ); 85 | 86 | const updatedAppProps$ = appProps$.pipe( 87 | take(1), 88 | withLatestFrom(app$), 89 | switchMap(([props, handle]) => { 90 | const newProps = Object.assign({ foo: "bar" }, props); 91 | return handle.ask(SetAppProperties, newProps); 92 | }), 93 | switchMap(() => 94 | app$.pipe( 95 | switchMap(handle => handle.ask(GetAppProperties)), 96 | publishReplay(1), 97 | refCount() 98 | ) 99 | ) 100 | ); 101 | 102 | it("should add a property 'foo' that equals 'bar'", function(done) { 103 | updatedAppProps$.subscribe(props => { 104 | expect(props.foo).to.equal("bar"); 105 | done(); 106 | }); 107 | }); 108 | }); 109 | 110 | describe("getObject", function() { 111 | const obj$ = app$.pipe( 112 | switchMap(h => h.ask(GetObject, "fpZbty")), 113 | shareReplay(1) 114 | ); 115 | 116 | it("should return a Handle of type 'GenericObject'", function(done) { 117 | obj$.subscribe(h => { 118 | expect(h).to.be.instanceof(Handle); 119 | expect(h.qClass).to.equal("GenericObject"); 120 | done(); 121 | }); 122 | }); 123 | }); 124 | 125 | describe("createSessionObject", function() { 126 | const obj$ = app$.pipe( 127 | switchMap(h => 128 | h.ask(CreateSessionObject, { 129 | qInfo: { 130 | qType: "e2e-test" 131 | } 132 | }) 133 | ), 134 | shareReplay(1) 135 | ); 136 | 137 | it("should return a Handle of type 'GenericObject'", function(done) { 138 | obj$.subscribe(h => { 139 | expect(h).to.be.instanceof(Handle); 140 | expect(h.qClass).to.equal("GenericObject"); 141 | done(); 142 | }); 143 | }); 144 | }); 145 | 146 | describe("createObject", function() { 147 | const obj$ = app$.pipe( 148 | switchMap(h => 149 | h.ask(CreateObject, { 150 | qInfo: { 151 | qType: "e2e-test" 152 | } 153 | }) 154 | ), 155 | shareReplay(1) 156 | ); 157 | 158 | it("should return a Handle of type 'GenericObject'", function(done) { 159 | obj$.subscribe(h => { 160 | expect(h).to.be.instanceof(Handle); 161 | expect(h.qClass).to.equal("GenericObject"); 162 | done(); 163 | }); 164 | }); 165 | }); 166 | 167 | describe("invalidation", function() { 168 | it("should receive an invalidation event when properties change", function(done) { 169 | // Listen for invalidation event 170 | app$.subscribe(h => { 171 | h.invalidated$.pipe(take(1)).subscribe(i => { 172 | done(); 173 | }); 174 | }); 175 | 176 | // Trigger invalidation event by changing app events 177 | const appProps$ = app$.pipe( 178 | switchMap(handle => handle.ask(GetAppProperties)), 179 | publishReplay(1), 180 | refCount() 181 | ); 182 | 183 | appProps$ 184 | .pipe( 185 | take(1), 186 | withLatestFrom(app$), 187 | switchMap(([props, handle]) => { 188 | const newProps = Object.assign({ test: "invalid" }, props); 189 | return handle.ask(SetAppProperties, newProps); 190 | }) 191 | ) 192 | .subscribe(); 193 | }); 194 | }); 195 | 196 | after(function(done) { 197 | container$.subscribe(container => 198 | container.kill((err, result) => { 199 | container.remove(); 200 | done(); 201 | }) 202 | ); 203 | }); 204 | }); 205 | } 206 | 207 | module.exports = testDoc; 208 | -------------------------------------------------------------------------------- /test/e2e/notification.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | publish, 8 | publishReplay, 9 | refCount, 10 | shareReplay, 11 | switchMap, 12 | map, 13 | tap, 14 | take, 15 | takeUntil, 16 | withLatestFrom, 17 | filter, 18 | reduce, 19 | skip 20 | } = require("rxjs/operators"); 21 | var { Subject } = require("rxjs"); 22 | 23 | var { EngineVersion, OpenDoc } = require("../../dist/global"); 24 | var { connectSession } = require("../../dist"); 25 | 26 | var { GetAppProperties, SetAppProperties } = require("../../dist/doc"); 27 | 28 | var { port, image } = require("./config.json"); 29 | 30 | // launch a new container 31 | var container$ = createContainer(image, port); 32 | 33 | var session$ = container$.pipe( 34 | map(() => 35 | connectSession({ 36 | host: "localhost", 37 | port: port, 38 | isSecure: false 39 | }) 40 | ), 41 | publishReplay(1), 42 | refCount() 43 | ); 44 | 45 | const eng$ = session$.pipe(switchMap(session => session.global$)); 46 | 47 | const app$ = eng$.pipe( 48 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")), 49 | publishReplay(1), 50 | refCount() 51 | ); 52 | 53 | const notifications$ = session$.pipe( 54 | switchMap(session => session.notifications$) 55 | ); 56 | 57 | function testNotification() { 58 | describe("Notifications", function() { 59 | before(function(done) { 60 | this.timeout(10000); 61 | app$.subscribe(() => done()); 62 | }); 63 | 64 | it("should emit any traffic sent", function(done) { 65 | this.timeout(10000); 66 | const session$ = container$.pipe( 67 | map(() => { 68 | return connectSession({ 69 | host: "localhost", 70 | port: port, 71 | isSecure: false 72 | }); 73 | }), 74 | publishReplay(1), 75 | refCount() 76 | ); 77 | 78 | const eng$ = session$.pipe(switchMap(session => session.global$)); 79 | 80 | // after app opened, start listening on notifications 81 | session$ 82 | .pipe( 83 | switchMap(session => session.notifications$), 84 | filter(f => f.type === "traffic:sent"), 85 | skip(1), // skip the opening message 86 | take(1) 87 | ) 88 | .subscribe(req => { 89 | expect(req.data.method).to.equal("EngineVersion"); 90 | done(); 91 | }); 92 | 93 | // After app has been opened, send a call 94 | eng$ 95 | .pipe( 96 | switchMap(h => h.ask(EngineVersion)), 97 | publish() 98 | ) 99 | .connect(); 100 | }); 101 | 102 | it("should emit any traffic received", function(done) { 103 | this.timeout(10000); 104 | const session$ = container$.pipe( 105 | map(() => { 106 | return connectSession({ 107 | host: "localhost", 108 | port: port, 109 | isSecure: false 110 | }); 111 | }), 112 | publishReplay(1), 113 | refCount() 114 | ); 115 | 116 | const eng$ = session$.pipe(switchMap(session => session.global$)); 117 | const notifications$ = session$.pipe( 118 | switchMap(session => session.notifications$) 119 | ); 120 | 121 | const msgId$ = notifications$.pipe( 122 | filter( 123 | f => (f.type === "traffic:sent") & (f.data.method === "EngineVersion") 124 | ), 125 | map(req => req.data.id) 126 | ); 127 | 128 | notifications$ 129 | .pipe( 130 | filter(f => f.type === "traffic:received"), 131 | withLatestFrom(msgId$), 132 | take(1) 133 | ) 134 | .subscribe(([resp, reqId]) => { 135 | expect(resp.data.id).to.equal(reqId); 136 | done(); 137 | }); 138 | 139 | // After app has been opened, send a call 140 | eng$ 141 | .pipe( 142 | switchMap(h => h.ask(EngineVersion)), 143 | publish() 144 | ) 145 | .connect(); 146 | }); 147 | 148 | it("should emit the suspended status", function(done) { 149 | notifications$ 150 | .pipe( 151 | filter(f => f.type === "traffic:suspend-status"), 152 | map(f => f.data), 153 | take(3), 154 | reduce((acc, curr) => [...acc, curr], []) 155 | ) 156 | .subscribe(statusHistory => { 157 | expect(statusHistory[0]).to.equal(false); 158 | expect(statusHistory[1]).to.equal(true); 159 | expect(statusHistory[2]).to.equal(false); 160 | done(); 161 | }); 162 | 163 | app$.subscribe(h => { 164 | h.session.suspended$.next(true); 165 | h.session.suspended$.next(false); 166 | }); 167 | }); 168 | 169 | it("should emit the changes that the session passes down", function(done) { 170 | const setAppProps$ = app$.pipe( 171 | switchMap(handle => handle.ask(GetAppProperties)), 172 | take(1), 173 | withLatestFrom(app$), 174 | switchMap(([props, handle]) => { 175 | const newProps = Object.assign({ test: "invalid" }, props); 176 | return handle.ask(SetAppProperties, newProps); 177 | }), 178 | publish() 179 | ); 180 | 181 | const appHandle$ = app$.pipe(map(h => h.handle)); 182 | 183 | notifications$ 184 | .pipe( 185 | filter(f => f.type === "traffic:change"), 186 | withLatestFrom(appHandle$) 187 | ) 188 | .subscribe(([notification, handle]) => { 189 | const didHandleChange = notification.data.indexOf(handle) > -1; 190 | expect(didHandleChange).to.equal(true); 191 | done(); 192 | }); 193 | 194 | setAppProps$.connect(); 195 | }); 196 | 197 | it("should emit a close event when the session socket closes", function(done) { 198 | const session$ = container$.pipe( 199 | map(() => { 200 | return connectSession({ 201 | host: "localhost", 202 | port: port, 203 | isSecure: false 204 | }); 205 | }), 206 | publishReplay(1), 207 | refCount() 208 | ); 209 | 210 | const eng$ = session$.pipe(switchMap(session => session.global$)); 211 | const notifications$ = session$.pipe( 212 | switchMap(session => session.notifications$) 213 | ); 214 | 215 | const ws$ = eng$.pipe(switchMap(h => h.session.ws$)); 216 | 217 | notifications$ 218 | .pipe( 219 | filter(f => f.type === "socket:close"), 220 | map(m => m.data), 221 | take(1) 222 | ) 223 | .subscribe(evt => { 224 | expect(evt.type).to.equal("close"); 225 | done(); 226 | }); 227 | 228 | ws$.subscribe(ws => ws.close()); 229 | }); 230 | 231 | after(function(done) { 232 | container$.subscribe(container => 233 | container.kill((err, result) => { 234 | container.remove(); 235 | done(); 236 | }) 237 | ); 238 | }); 239 | }); 240 | } 241 | 242 | module.exports = testNotification; 243 | -------------------------------------------------------------------------------- /test/e2e/delta.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | chai.use(require("chai-generator")); 3 | const expect = chai.expect; 4 | 5 | var createContainer = require("../util/create-container"); 6 | var { 7 | shareReplay, 8 | switchMap, 9 | filter, 10 | map, 11 | pairwise, 12 | take, 13 | withLatestFrom 14 | } = require("rxjs/operators"); 15 | var { combineLatest } = require("rxjs"); 16 | 17 | var { EngineVersion, OpenDoc, IsDesktopMode } = require("../../dist/global"); 18 | var { GetAppProperties, SetAppProperties } = require("../../dist/Doc"); 19 | var { connectSession, qAsk, qAskReplay, invalidations } = require("../../dist"); 20 | 21 | var { port, image } = require("./config.json"); 22 | 23 | // launch a new container 24 | var container$ = createContainer(image, port); 25 | 26 | const session$ = container$.pipe( 27 | map(() => { 28 | return connectSession({ 29 | host: "localhost", 30 | port: port, 31 | isSecure: false, 32 | delta: true 33 | }); 34 | }), 35 | shareReplay(1) 36 | ); 37 | 38 | const eng$ = session$.pipe(switchMap(session => session.global$)); 39 | 40 | const sessionSelectiveDelta$ = container$.pipe( 41 | map(() => { 42 | return connectSession({ 43 | host: "localhost", 44 | port: port, 45 | isSecure: false, 46 | delta: { 47 | Global: ["IsDesktopMode"] 48 | } 49 | }); 50 | }), 51 | shareReplay(1) 52 | ); 53 | 54 | const engSelectiveDelta$ = sessionSelectiveDelta$.pipe( 55 | switchMap(session => session.global$) 56 | ); 57 | 58 | function testDelta() { 59 | describe("Delta Mode", function() { 60 | before(function(done) { 61 | this.timeout(10000); 62 | container$.subscribe(() => done()); 63 | }); 64 | 65 | describe("Global Delta", function() { 66 | const ev$ = eng$.pipe(switchMap(handle => handle.ask(EngineVersion))); 67 | 68 | const notifications$ = session$.pipe( 69 | switchMap(session => session.notifications$) 70 | ); 71 | 72 | describe("Engine Version", function() { 73 | it("should return an object with property qComponentVersion", done => { 74 | ev$.subscribe(ev => { 75 | expect(ev).to.have.property("qComponentVersion"); 76 | done(); 77 | }); 78 | }); 79 | 80 | it("should return the object when called a second time after receiving an empty patch from the engine", done => { 81 | const receivedPatch$ = notifications$.pipe( 82 | filter(notification => notification.type === "traffic:received"), 83 | map(notification => notification.data.result.qVersion), 84 | take(1) 85 | ); 86 | 87 | combineLatest(ev$, receivedPatch$).subscribe(([ev, patch]) => { 88 | expect(ev).to.have.property("qComponentVersion"); 89 | expect(patch.length).to.equal(0); 90 | done(); 91 | }); 92 | }); 93 | }); 94 | 95 | const doc$ = eng$.pipe( 96 | switchMap(handle => handle.ask(OpenDoc, "iris.qvf")) 97 | ); 98 | 99 | describe("Getting a Doc Handle", function() { 100 | it("should return a handle with qClass 'Doc'", done => { 101 | doc$.subscribe(handle => { 102 | expect(handle.qClass).to.equal("Doc"); 103 | done(); 104 | }); 105 | }); 106 | 107 | it("should return the handle with qClass 'Doc' a second time", done => { 108 | doc$.subscribe(handle => { 109 | expect(handle.qClass).to.equal("Doc"); 110 | done(); 111 | }); 112 | }); 113 | }); 114 | 115 | describe("Updating Props", function() { 116 | it("should return the app properties", done => { 117 | const appProps$ = doc$.pipe( 118 | switchMap(handle => handle.ask(GetAppProperties)) 119 | ); 120 | 121 | appProps$.subscribe(props => { 122 | expect(props.foo).to.be.undefined; 123 | done(); 124 | }); 125 | }); 126 | 127 | it("should return updated app properties after a change is made", done => { 128 | const getAppProps$ = doc$.pipe( 129 | switchMap(handle => handle.ask(GetAppProperties)) 130 | ); 131 | 132 | getAppProps$ 133 | .pipe( 134 | switchMap(props => 135 | doc$.pipe( 136 | switchMap(handle => 137 | handle.ask(SetAppProperties, { ...props, foo: "bar" }) 138 | ) 139 | ) 140 | ), 141 | switchMap(() => getAppProps$) 142 | ) 143 | .subscribe(props => { 144 | expect(props.foo).to.equal("bar"); 145 | done(); 146 | }); 147 | }); 148 | }); 149 | describe("Patch Immutability", function() { 150 | it("should not mutate target paths and keep non-patched paths", done => { 151 | const testObj = doc$.pipe( 152 | qAskReplay("CreateSessionObject", { 153 | qInfo: { qType: "test" }, 154 | foo: { 155 | a: 1 156 | }, 157 | bar: { 158 | b: 2 159 | } 160 | }) 161 | ); 162 | 163 | const layouts = testObj.pipe( 164 | invalidations(true), 165 | qAsk("GetLayout"), 166 | pairwise(), 167 | take(2) 168 | ); 169 | 170 | layouts.subscribe(([prevLayout, currLayout]) => { 171 | expect(prevLayout.foo).to.equal(currLayout.foo); 172 | expect(prevLayout.bar).to.not.equal(currLayout.bar); 173 | done(); 174 | }); 175 | 176 | testObj 177 | .pipe( 178 | qAsk("SetProperties", { 179 | qInfo: { qType: "test" }, 180 | foo: { 181 | a: 1 182 | }, 183 | bar: { 184 | b: 3 185 | } 186 | }), 187 | take(1) 188 | ) 189 | .subscribe(); 190 | }); 191 | }); 192 | }); 193 | 194 | describe("Selective Delta", function() { 195 | const selectiveNotifications$ = sessionSelectiveDelta$.pipe( 196 | switchMap(session => session.notifications$), 197 | filter(f => f.type === "traffic:received") 198 | ); 199 | 200 | it("should not use delta for EngineVersion", done => { 201 | const ev$ = engSelectiveDelta$.pipe( 202 | switchMap(handle => handle.ask(EngineVersion)) 203 | ); 204 | 205 | ev$ 206 | .pipe( 207 | withLatestFrom(selectiveNotifications$), 208 | take(1) 209 | ) 210 | .subscribe(([response, notification]) => { 211 | expect(notification.data.delta).to.be.undefined; 212 | done(); 213 | }); 214 | }); 215 | 216 | it("should use delta for IsDesktopMode", done => { 217 | const isDesktop$ = engSelectiveDelta$.pipe( 218 | switchMap(handle => handle.ask(IsDesktopMode)) 219 | ); 220 | 221 | isDesktop$ 222 | .pipe( 223 | withLatestFrom(selectiveNotifications$), 224 | take(1) 225 | ) 226 | .subscribe(([response, notification]) => { 227 | expect(notification.data.delta).to.equal(true); 228 | done(); 229 | }); 230 | }); 231 | }); 232 | 233 | after(function(done) { 234 | container$.subscribe(container => 235 | container.kill((err, result) => { 236 | container.remove(); 237 | done(); 238 | }) 239 | ); 240 | }); 241 | }); 242 | } 243 | 244 | module.exports = testDelta; 245 | -------------------------------------------------------------------------------- /src/session.js: -------------------------------------------------------------------------------- 1 | import { 2 | Observable, 3 | Subject, 4 | BehaviorSubject, 5 | of as $of, 6 | throwError as $throw, 7 | merge, 8 | concat 9 | } from "rxjs"; 10 | 11 | import { 12 | groupBy, 13 | partition, 14 | publishLast, 15 | refCount, 16 | map, 17 | withLatestFrom, 18 | publish, 19 | filter, 20 | mergeMap, 21 | concatMap, 22 | take, 23 | mapTo, 24 | distinctUntilChanged, 25 | pluck, 26 | switchMap, 27 | takeUntil, 28 | ignoreElements, 29 | tap, 30 | scan 31 | } from "rxjs/operators"; 32 | 33 | import Handle from "./handle"; 34 | import connectWS from "./util/connectWS"; 35 | import { applyPatches } from "immer"; 36 | 37 | export default class Session { 38 | constructor(config) { 39 | const session = this; 40 | 41 | // closed signal 42 | this.closed$ = new Subject(); 43 | 44 | // delta mode 45 | const delta = config.delta || false; 46 | 47 | // Suspended changes state 48 | const suspended$ = new BehaviorSubject(false); 49 | 50 | // Connect WS 51 | const ws$ = Observable.create(observer => { 52 | // If they supplied a WebSocket, use it. Otherwise, build one 53 | if (typeof config.ws !== "undefined") { 54 | observer.next(config.ws); 55 | observer.complete(); 56 | } else { 57 | var ws = connectWS(config); 58 | 59 | ws.addEventListener("open", evt => { 60 | observer.next(ws); 61 | observer.complete(); 62 | }); 63 | } 64 | 65 | return; 66 | }).pipe( 67 | publishLast(), 68 | refCount() 69 | ); 70 | 71 | // On close signal, execute side effect to close the websocket 72 | this.closed$ 73 | .pipe( 74 | withLatestFrom(ws$), 75 | take(1) 76 | ) 77 | .subscribe(([close, ws]) => ws.close()); 78 | 79 | // WebSocket close events 80 | const wsClose$ = ws$.pipe( 81 | switchMap(ws => 82 | Observable.create(observer => { 83 | ws.addEventListener("close", evt => { 84 | observer.next(evt); 85 | observer.complete(); 86 | }); 87 | }) 88 | ), 89 | // Side effects when websocket gets closed 90 | tap(() => { 91 | // complete the requests stream 92 | requests$.complete(); 93 | // complete the suspended stream 94 | suspended$.complete(); 95 | }) 96 | ); 97 | 98 | // Requests 99 | const requests$ = new Subject(); 100 | 101 | // Hook in request pipeline 102 | requests$ 103 | .pipe( 104 | withLatestFrom(ws$), 105 | takeUntil(wsClose$) 106 | ) 107 | .subscribe(([req, ws]) => { 108 | const request = { 109 | id: req.id, 110 | handle: req.handle, 111 | method: req.method, 112 | params: req.params 113 | }; 114 | 115 | // Set delta if necessary 116 | if (typeof config.delta === "object") { 117 | const overrides = config.delta[req.qClass] || []; 118 | if (overrides.indexOf(req.method) > -1) { 119 | request.delta = true; 120 | } 121 | } else if (config.delta === true) { 122 | request.delta = true; 123 | } 124 | 125 | ws.send(JSON.stringify(request)); 126 | }); 127 | 128 | // Responses 129 | const responses$ = ws$.pipe( 130 | concatMap(ws => 131 | Observable.create(observer => { 132 | ws.addEventListener("message", evt => { 133 | const response = JSON.parse(evt.data); 134 | observer.next(response); 135 | }); 136 | 137 | ws.addEventListener("error", err => { 138 | observer.error(err); 139 | }); 140 | 141 | ws.addEventListener("close", function() { 142 | observer.complete(); 143 | }); 144 | }) 145 | ), 146 | publish(), 147 | refCount() 148 | ); 149 | 150 | // Link responses with requests 151 | const responsesWithRequest$ = requests$.pipe( 152 | // this prevents errors from going through the delta processing chain. is that appropriate? 153 | filter(response => !response.hasOwnProperty("error")), 154 | mergeMap(req => 155 | responses$.pipe( 156 | filter(response => req.id === response.id), 157 | take(1), 158 | map(response => ({ 159 | request: req, 160 | response: response 161 | })) 162 | ) 163 | ) 164 | ); 165 | 166 | // Responses with errors 167 | const errorResponses$ = responses$.pipe( 168 | filter(resp => resp.hasOwnProperty("error")) 169 | ); 170 | 171 | // Split direct responses from delta responses 172 | const [directResponse$, deltaResponses$] = responsesWithRequest$.pipe( 173 | filter(reqResp => !reqResp.response.hasOwnProperty("error")), 174 | partition(reqResp => !reqResp.response.delta) 175 | ); 176 | 177 | // Apply JSON Patching to delta responses 178 | const deltaResponsesCalculated$ = deltaResponses$.pipe( 179 | groupBy( 180 | reqResp => `${reqResp.request.handle} - ${reqResp.request.method}` 181 | ), 182 | mergeMap(grouped$ => 183 | grouped$.pipe( 184 | scan( 185 | (acc, reqResp) => { 186 | const { response } = reqResp; 187 | const resultKeys = Object.keys(response.result); 188 | const patches = resultKeys.reduce((acc, key) => { 189 | const keyPatches = response.result[key]; 190 | const transformedPatches = keyPatches.map(patch => { 191 | // Keep in mind that engine patch root path is out of compliance with JSON-Pointer spec used by JSON-Patch spec for "/" 192 | // https://tools.ietf.org/html/rfc6902#page-3 193 | // https://tools.ietf.org/html/rfc6901#section-5 194 | const arrayPath = patch.path.split("/").filter(s => s !== ""); 195 | return { 196 | ...patch, 197 | path: [key, ...arrayPath] 198 | }; 199 | }); 200 | 201 | return [...acc, ...transformedPatches]; 202 | }, []); 203 | return { 204 | ...reqResp, 205 | response: { 206 | ...reqResp.response, 207 | result: applyPatches(acc.response.result, patches) 208 | } 209 | }; 210 | }, 211 | { 212 | response: { 213 | result: {} 214 | } 215 | } 216 | ) 217 | ) 218 | ) 219 | ); 220 | 221 | // Merge the direct and delta responses back together and parse them 222 | const mappedResponses$ = merge( 223 | directResponse$.pipe(map(reqResp => reqResp.response)), 224 | deltaResponsesCalculated$.pipe(map(reqResp => reqResp.response)) 225 | ).pipe( 226 | map(response => { 227 | const result = response.result; 228 | const resultKeys = Object.keys(result); 229 | if ( 230 | result.hasOwnProperty("qReturn") && 231 | result.qReturn.hasOwnProperty("qHandle") 232 | ) { 233 | return { 234 | id: response.id, 235 | result: new Handle( 236 | this, 237 | result.qReturn.qHandle, 238 | result.qReturn.qType 239 | ) 240 | }; 241 | } else if (resultKeys.length === 1) { 242 | return { 243 | id: response.id, 244 | result: result[resultKeys[0]] 245 | }; 246 | } else { 247 | return response; 248 | } 249 | }) 250 | ); 251 | 252 | // Publish response stream 253 | const finalResponse$ = merge(mappedResponses$, errorResponses$).pipe( 254 | publish() 255 | ); 256 | 257 | // Connect the response stream 258 | const finalResponseSub = finalResponse$.connect(); 259 | 260 | // Changes 261 | const changesIn$ = responses$.pipe( 262 | filter(f => f.hasOwnProperty("change")), 263 | pluck("change") 264 | ); 265 | 266 | // Buffer changes during suspends 267 | const changes$ = changesIn$.pipe(bufferInvalids(suspended$)); 268 | 269 | // Session Notifications 270 | const notifications$ = merge( 271 | requests$.pipe( 272 | map(req => ({ 273 | type: "traffic:sent", 274 | data: req 275 | })) 276 | ), 277 | responses$.pipe( 278 | map(resp => ({ 279 | type: "traffic:received", 280 | data: resp 281 | })) 282 | ), 283 | changes$.pipe( 284 | map(changes => ({ 285 | type: "traffic:change", 286 | data: changes 287 | })) 288 | ), 289 | suspended$.pipe( 290 | map(suspend => ({ 291 | type: "traffic:suspend-status", 292 | data: suspend 293 | })) 294 | ), 295 | wsClose$.pipe( 296 | map(evt => ({ 297 | type: "socket:close", 298 | data: evt 299 | })) 300 | ) 301 | ); 302 | 303 | // Sequence generator 304 | this.seqGen = (function*() { 305 | var index = 1; 306 | while (true) yield index++; 307 | })(); 308 | 309 | Object.assign(this, { 310 | ws$, 311 | requests$, 312 | finalResponse$, 313 | changes$, 314 | suspended$, 315 | notifications$, 316 | delta 317 | }); 318 | } 319 | 320 | ask(action) { 321 | const requestId = this.seqGen.next().value; 322 | 323 | const request = { 324 | id: requestId, 325 | jsonrpc: "2.0", 326 | handle: action.handle, 327 | method: action.method, 328 | params: action.params, 329 | qClass: action.qClass 330 | }; 331 | 332 | this.requests$.next(request); 333 | 334 | return this.finalResponse$.pipe( 335 | filter(r => r.id === requestId), 336 | mergeMap(m => { 337 | if (m.hasOwnProperty("error")) { 338 | return $throw(m.error); 339 | } else { 340 | return $of(m); 341 | } 342 | }), 343 | map(m => m.result), 344 | take(1) 345 | ); 346 | } 347 | 348 | global() { 349 | const globalHandle = new Handle(this, -1, "Global"); 350 | 351 | // ask for a sample call to test that we are authenticated properly, then either pass global or pass the error 352 | return this.ws$.pipe( 353 | switchMap(() => 354 | this.ask({ 355 | handle: -1, 356 | method: "GetUniqueID", 357 | params: [], 358 | qClass: "Global" 359 | }) 360 | ), 361 | mapTo(globalHandle), 362 | publishLast(), 363 | refCount() 364 | ); 365 | } 366 | 367 | close() { 368 | this.closed$.next(null); 369 | } 370 | } 371 | 372 | function bufferInvalids(status$) { 373 | return function(src$) { 374 | const values = []; 375 | const directStream$ = new Observable.create(observer => { 376 | return src$.pipe(withLatestFrom(status$)).subscribe( 377 | ([val, status]) => { 378 | if (status) { 379 | values.push(...val); 380 | } else { 381 | observer.next(val); 382 | } 383 | }, 384 | err => observer.error(err), 385 | () => observer.complete() 386 | ); 387 | }); 388 | 389 | const bufferStream$ = status$.pipe( 390 | distinctUntilChanged(), 391 | filter(f => !f), 392 | map(() => values), 393 | filter(f => f.length > 0), 394 | takeUntil(concat(src$.pipe(ignoreElements()), $of(undefined))) 395 | ); 396 | 397 | return merge(directStream$, bufferStream$); 398 | }; 399 | } 400 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright (c) 2016-2018 Speros Kokenes 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/schema/schema.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "Field": { 3 | "GetCardinal": [ 4 | "qReturn" 5 | ], 6 | "GetAndMode": [ 7 | "qReturn" 8 | ], 9 | "SelectValues": [ 10 | "qReturn" 11 | ], 12 | "Select": [ 13 | "qReturn" 14 | ], 15 | "ToggleSelect": [ 16 | "qReturn" 17 | ], 18 | "ClearAllButThis": [ 19 | "qReturn" 20 | ], 21 | "SelectPossible": [ 22 | "qReturn" 23 | ], 24 | "SelectExcluded": [ 25 | "qReturn" 26 | ], 27 | "SelectAll": [ 28 | "qReturn" 29 | ], 30 | "Lock": [ 31 | "qReturn" 32 | ], 33 | "Unlock": [ 34 | "qReturn" 35 | ], 36 | "GetNxProperties": [ 37 | "qProperties" 38 | ], 39 | "SetNxProperties": [], 40 | "SetAndMode": [], 41 | "SelectAlternative": [ 42 | "qReturn" 43 | ], 44 | "LowLevelSelect": [ 45 | "qReturn" 46 | ], 47 | "Clear": [ 48 | "qReturn" 49 | ] 50 | }, 51 | "Variable": { 52 | "GetContent": [ 53 | "qContent" 54 | ], 55 | "GetRawContent": [ 56 | "qReturn" 57 | ], 58 | "SetContent": [ 59 | "qReturn" 60 | ], 61 | "ForceContent": [], 62 | "GetNxProperties": [ 63 | "qProperties" 64 | ], 65 | "SetNxProperties": [] 66 | }, 67 | "GenericObject": { 68 | "GetLayout": [ 69 | "qLayout" 70 | ], 71 | "GetListObjectData": [ 72 | "qDataPages" 73 | ], 74 | "GetHyperCubeData": [ 75 | "qDataPages" 76 | ], 77 | "GetHyperCubeReducedData": [ 78 | "qDataPages" 79 | ], 80 | "GetHyperCubePivotData": [ 81 | "qDataPages" 82 | ], 83 | "GetHyperCubeStackData": [ 84 | "qDataPages" 85 | ], 86 | "GetHyperCubeContinuousData": [ 87 | "qDataPages", 88 | "qAxisData" 89 | ], 90 | "GetHyperCubeTreeData": [ 91 | "qNodes" 92 | ], 93 | "GetHyperCubeBinnedData": [ 94 | "qDataPages" 95 | ], 96 | "ApplyPatches": [], 97 | "ClearSoftPatches": [], 98 | "SetProperties": [], 99 | "GetProperties": [ 100 | "qProp" 101 | ], 102 | "GetEffectiveProperties": [ 103 | "qProp" 104 | ], 105 | "SetFullPropertyTree": [], 106 | "GetFullPropertyTree": [ 107 | "qPropEntry" 108 | ], 109 | "GetInfo": [ 110 | "qInfo" 111 | ], 112 | "ClearSelections": [], 113 | "ExportData": [ 114 | "qUrl", 115 | "qWarnings" 116 | ], 117 | "SelectListObjectValues": [ 118 | "qSuccess" 119 | ], 120 | "SelectListObjectPossible": [ 121 | "qSuccess" 122 | ], 123 | "SelectListObjectExcluded": [ 124 | "qSuccess" 125 | ], 126 | "SelectListObjectAlternative": [ 127 | "qSuccess" 128 | ], 129 | "SelectListObjectAll": [ 130 | "qSuccess" 131 | ], 132 | "SelectListObjectContinuousRange": [ 133 | "qSuccess" 134 | ], 135 | "SearchListObjectFor": [ 136 | "qSuccess" 137 | ], 138 | "AbortListObjectSearch": [], 139 | "AcceptListObjectSearch": [], 140 | "ExpandLeft": [], 141 | "ExpandTop": [], 142 | "CollapseLeft": [], 143 | "CollapseTop": [], 144 | "DrillUp": [], 145 | "Lock": [], 146 | "Unlock": [], 147 | "SelectHyperCubeValues": [ 148 | "qSuccess" 149 | ], 150 | "SelectHyperCubeCells": [ 151 | "qSuccess" 152 | ], 153 | "SelectPivotCells": [ 154 | "qSuccess" 155 | ], 156 | "RangeSelectHyperCubeValues": [ 157 | "qSuccess" 158 | ], 159 | "MultiRangeSelectHyperCubeValues": [ 160 | "qSuccess" 161 | ], 162 | "MultiRangeSelectTreeDataValues": [ 163 | "qSuccess" 164 | ], 165 | "SelectHyperCubeContinuousRange": [ 166 | "qSuccess" 167 | ], 168 | "GetChild": [ 169 | "qReturn" 170 | ], 171 | "GetChildInfos": [ 172 | "qInfos" 173 | ], 174 | "CreateChild": [ 175 | "qInfo", 176 | "qReturn" 177 | ], 178 | "DestroyChild": [ 179 | "qSuccess" 180 | ], 181 | "DestroyAllChildren": [], 182 | "SetChildArrayOrder": [], 183 | "GetLinkedObjects": [ 184 | "qItems" 185 | ], 186 | "CopyFrom": [], 187 | "BeginSelections": [], 188 | "EndSelections": [], 189 | "ResetMadeSelections": [], 190 | "EmbedSnapshotObject": [], 191 | "GetSnapshotObject": [ 192 | "qReturn" 193 | ], 194 | "Publish": [], 195 | "UnPublish": [], 196 | "Approve": [], 197 | "UnApprove": [] 198 | }, 199 | "GenericDimension": { 200 | "GetLayout": [ 201 | "qLayout" 202 | ], 203 | "ApplyPatches": [], 204 | "SetProperties": [], 205 | "GetProperties": [ 206 | "qProp" 207 | ], 208 | "GetInfo": [ 209 | "qInfo" 210 | ], 211 | "GetDimension": [ 212 | "qDim" 213 | ], 214 | "GetLinkedObjects": [ 215 | "qItems" 216 | ], 217 | "Publish": [], 218 | "UnPublish": [], 219 | "Approve": [], 220 | "UnApprove": [] 221 | }, 222 | "GenericBookmark": { 223 | "GetFieldValues": [ 224 | "qFieldValues" 225 | ], 226 | "GetLayout": [ 227 | "qLayout" 228 | ], 229 | "ApplyPatches": [], 230 | "SetProperties": [], 231 | "GetProperties": [ 232 | "qProp" 233 | ], 234 | "GetInfo": [ 235 | "qInfo" 236 | ], 237 | "Apply": [ 238 | "qSuccess" 239 | ], 240 | "Publish": [], 241 | "UnPublish": [], 242 | "Approve": [], 243 | "UnApprove": [] 244 | }, 245 | "GenericVariable": { 246 | "GetLayout": [ 247 | "qLayout" 248 | ], 249 | "ApplyPatches": [], 250 | "SetProperties": [], 251 | "GetProperties": [ 252 | "qProp" 253 | ], 254 | "GetInfo": [ 255 | "qInfo" 256 | ], 257 | "SetStringValue": [], 258 | "SetNumValue": [], 259 | "SetDualValue": [] 260 | }, 261 | "GenericMeasure": { 262 | "GetLayout": [ 263 | "qLayout" 264 | ], 265 | "ApplyPatches": [], 266 | "SetProperties": [], 267 | "GetProperties": [ 268 | "qProp" 269 | ], 270 | "GetInfo": [ 271 | "qInfo" 272 | ], 273 | "GetMeasure": [ 274 | "qMeasure" 275 | ], 276 | "GetLinkedObjects": [ 277 | "qItems" 278 | ], 279 | "Publish": [], 280 | "UnPublish": [], 281 | "Approve": [], 282 | "UnApprove": [] 283 | }, 284 | "Doc": { 285 | "GetField": [ 286 | "qReturn" 287 | ], 288 | "GetFieldDescription": [ 289 | "qReturn" 290 | ], 291 | "GetVariable": [ 292 | "qReturn" 293 | ], 294 | "GetLooselyCoupledVector": [ 295 | "qv" 296 | ], 297 | "SetLooselyCoupledVector": [ 298 | "qReturn" 299 | ], 300 | "Evaluate": [ 301 | "qReturn" 302 | ], 303 | "EvaluateEx": [ 304 | "qValue" 305 | ], 306 | "ClearAll": [], 307 | "LockAll": [], 308 | "UnlockAll": [], 309 | "Back": [], 310 | "Forward": [], 311 | "CreateVariable": [ 312 | "qReturn" 313 | ], 314 | "RemoveVariable": [ 315 | "qReturn" 316 | ], 317 | "GetLocaleInfo": [ 318 | "qReturn" 319 | ], 320 | "GetTablesAndKeys": [ 321 | "qtr", 322 | "qk" 323 | ], 324 | "GetViewDlgSaveInfo": [ 325 | "qReturn" 326 | ], 327 | "SetViewDlgSaveInfo": [], 328 | "GetEmptyScript": [ 329 | "qReturn" 330 | ], 331 | "DoReload": [ 332 | "qReturn" 333 | ], 334 | "GetScriptBreakpoints": [ 335 | "qBreakpoints" 336 | ], 337 | "SetScriptBreakpoints": [], 338 | "GetScript": [ 339 | "qScript" 340 | ], 341 | "GetTextMacros": [ 342 | "qMacros" 343 | ], 344 | "SetFetchLimit": [], 345 | "DoSave": [], 346 | "GetTableData": [ 347 | "qData" 348 | ], 349 | "GetAppLayout": [ 350 | "qLayout" 351 | ], 352 | "SetAppProperties": [], 353 | "GetAppProperties": [ 354 | "qProp" 355 | ], 356 | "GetLineage": [ 357 | "qLineage" 358 | ], 359 | "CreateSessionObject": [ 360 | "qReturn" 361 | ], 362 | "DestroySessionObject": [ 363 | "qSuccess" 364 | ], 365 | "CreateObject": [ 366 | "qInfo", 367 | "qReturn" 368 | ], 369 | "DestroyObject": [ 370 | "qSuccess" 371 | ], 372 | "GetObject": [ 373 | "qReturn" 374 | ], 375 | "GetObjects": [ 376 | "qList" 377 | ], 378 | "GetBookmarks": [ 379 | "qList" 380 | ], 381 | "CloneObject": [ 382 | "qCloneId" 383 | ], 384 | "CreateDraft": [ 385 | "qDraftId" 386 | ], 387 | "CommitDraft": [], 388 | "DestroyDraft": [ 389 | "qSuccess" 390 | ], 391 | "Undo": [ 392 | "qSuccess" 393 | ], 394 | "Redo": [ 395 | "qSuccess" 396 | ], 397 | "ClearUndoBuffer": [], 398 | "CreateDimension": [ 399 | "qInfo", 400 | "qReturn" 401 | ], 402 | "DestroyDimension": [ 403 | "qSuccess" 404 | ], 405 | "GetDimension": [ 406 | "qReturn" 407 | ], 408 | "CloneDimension": [ 409 | "qCloneId" 410 | ], 411 | "CreateMeasure": [ 412 | "qInfo", 413 | "qReturn" 414 | ], 415 | "DestroyMeasure": [ 416 | "qSuccess" 417 | ], 418 | "GetMeasure": [ 419 | "qReturn" 420 | ], 421 | "CloneMeasure": [ 422 | "qCloneId" 423 | ], 424 | "CreateSessionVariable": [ 425 | "qReturn" 426 | ], 427 | "DestroySessionVariable": [ 428 | "qSuccess" 429 | ], 430 | "CreateVariableEx": [ 431 | "qInfo", 432 | "qReturn" 433 | ], 434 | "DestroyVariableById": [ 435 | "qSuccess" 436 | ], 437 | "DestroyVariableByName": [ 438 | "qSuccess" 439 | ], 440 | "GetVariableById": [ 441 | "qReturn" 442 | ], 443 | "GetVariableByName": [ 444 | "qReturn" 445 | ], 446 | "CheckExpression": [ 447 | "qErrorMsg", 448 | "qBadFieldNames", 449 | "qDangerousFieldNames" 450 | ], 451 | "CheckNumberOrExpression": [ 452 | "qErrorMsg", 453 | "qBadFieldNames" 454 | ], 455 | "AddAlternateState": [], 456 | "RemoveAlternateState": [], 457 | "CreateBookmark": [ 458 | "qInfo", 459 | "qReturn" 460 | ], 461 | "DestroyBookmark": [ 462 | "qSuccess" 463 | ], 464 | "GetBookmark": [ 465 | "qReturn" 466 | ], 467 | "ApplyBookmark": [ 468 | "qSuccess" 469 | ], 470 | "CloneBookmark": [ 471 | "qCloneId" 472 | ], 473 | "AddFieldFromExpression": [ 474 | "qSuccess" 475 | ], 476 | "GetFieldOnTheFlyByName": [ 477 | "qName" 478 | ], 479 | "GetAllInfos": [ 480 | "qInfos" 481 | ], 482 | "Resume": [], 483 | "AbortModal": [], 484 | "Publish": [], 485 | "GetMatchingFields": [ 486 | "qFieldNames" 487 | ], 488 | "FindMatchingFields": [ 489 | "qFieldNames" 490 | ], 491 | "Scramble": [], 492 | "SaveObjects": [], 493 | "GetAssociationScores": [ 494 | "qScore" 495 | ], 496 | "GetMediaList": [ 497 | "qList", 498 | "qReturn" 499 | ], 500 | "GetContentLibraries": [ 501 | "qList" 502 | ], 503 | "GetLibraryContent": [ 504 | "qList" 505 | ], 506 | "DoReloadEx": [ 507 | "qResult" 508 | ], 509 | "BackCount": [ 510 | "qReturn" 511 | ], 512 | "ForwardCount": [ 513 | "qReturn" 514 | ], 515 | "SetScript": [], 516 | "CheckScriptSyntax": [ 517 | "qErrors" 518 | ], 519 | "GetFavoriteVariables": [ 520 | "qNames" 521 | ], 522 | "SetFavoriteVariables": [], 523 | "GetIncludeFileContent": [ 524 | "qContent" 525 | ], 526 | "CreateConnection": [ 527 | "qConnectionId" 528 | ], 529 | "ModifyConnection": [], 530 | "DeleteConnection": [], 531 | "GetConnection": [ 532 | "qConnection" 533 | ], 534 | "GetConnections": [ 535 | "qConnections" 536 | ], 537 | "GetDatabaseInfo": [ 538 | "qInfo" 539 | ], 540 | "GetDatabases": [ 541 | "qDatabases" 542 | ], 543 | "GetDatabaseOwners": [ 544 | "qOwners" 545 | ], 546 | "GetDatabaseTables": [ 547 | "qTables" 548 | ], 549 | "GetDatabaseTableFields": [ 550 | "qFields" 551 | ], 552 | "GetDatabaseTablePreview": [ 553 | "qPreview", 554 | "qRowCount" 555 | ], 556 | "GetFolderItemsForConnection": [ 557 | "qFolderItems" 558 | ], 559 | "GuessFileType": [ 560 | "qDataFormat" 561 | ], 562 | "GetFileTables": [ 563 | "qTables" 564 | ], 565 | "GetFileTableFields": [ 566 | "qFields", 567 | "qFormatSpec" 568 | ], 569 | "GetFileTablePreview": [ 570 | "qPreview", 571 | "qFormatSpec" 572 | ], 573 | "GetFileTablesEx": [ 574 | "qTables" 575 | ], 576 | "SendGenericCommandToCustomConnector": [ 577 | "qResult" 578 | ], 579 | "SearchSuggest": [ 580 | "qResult" 581 | ], 582 | "SearchAssociations": [ 583 | "qResults" 584 | ], 585 | "SelectAssociations": [], 586 | "SearchResults": [ 587 | "qResult" 588 | ], 589 | "SearchObjects": [ 590 | "qResult" 591 | ], 592 | "GetScriptEx": [ 593 | "qScript" 594 | ] 595 | }, 596 | "Global": { 597 | "AbortRequest": [], 598 | "AbortAll": [], 599 | "GetProgress": [ 600 | "qProgressData" 601 | ], 602 | "QvVersion": [ 603 | "qReturn" 604 | ], 605 | "OSVersion": [ 606 | "qReturn" 607 | ], 608 | "OSName": [ 609 | "qReturn" 610 | ], 611 | "QTProduct": [ 612 | "qReturn" 613 | ], 614 | "GetDocList": [ 615 | "qDocList" 616 | ], 617 | "GetInteract": [ 618 | "qDef", 619 | "qReturn" 620 | ], 621 | "InteractDone": [], 622 | "GetAuthenticatedUser": [ 623 | "qReturn" 624 | ], 625 | "CreateDocEx": [ 626 | "qDocId", 627 | "qReturn" 628 | ], 629 | "GetActiveDoc": [ 630 | "qReturn" 631 | ], 632 | "AllowCreateApp": [ 633 | "qReturn" 634 | ], 635 | "CreateApp": [ 636 | "qSuccess", 637 | "qAppId" 638 | ], 639 | "DeleteApp": [ 640 | "qSuccess" 641 | ], 642 | "IsDesktopMode": [ 643 | "qReturn" 644 | ], 645 | "CancelRequest": [], 646 | "ShutdownProcess": [], 647 | "ReloadExtensionList": [], 648 | "ReplaceAppFromID": [ 649 | "qSuccess" 650 | ], 651 | "CopyApp": [ 652 | "qSuccess" 653 | ], 654 | "ExportApp": [ 655 | "qSuccess" 656 | ], 657 | "PublishApp": [], 658 | "IsPersonalMode": [ 659 | "qReturn" 660 | ], 661 | "GetUniqueID": [ 662 | "qUniqueID" 663 | ], 664 | "OpenDoc": [ 665 | "qReturn" 666 | ], 667 | "CreateSessionApp": [ 668 | "qSessionAppId", 669 | "qReturn" 670 | ], 671 | "CreateSessionAppFromApp": [ 672 | "qSessionAppId", 673 | "qReturn" 674 | ], 675 | "ProductVersion": [ 676 | "qReturn" 677 | ], 678 | "GetAppEntry": [ 679 | "qEntry" 680 | ], 681 | "ConfigureReload": [], 682 | "CancelReload": [], 683 | "GetBNF": [ 684 | "qBnfDefs" 685 | ], 686 | "GetFunctions": [ 687 | "qFunctions" 688 | ], 689 | "GetOdbcDsns": [ 690 | "qOdbcDsns" 691 | ], 692 | "GetOleDbProviders": [ 693 | "qOleDbProviders" 694 | ], 695 | "GetDatabasesFromConnectionString": [ 696 | "qDatabases" 697 | ], 698 | "IsValidConnectionString": [ 699 | "qReturn" 700 | ], 701 | "GetDefaultAppFolder": [ 702 | "qPath" 703 | ], 704 | "GetLogicalDriveStrings": [ 705 | "qDrives" 706 | ], 707 | "GetFolderItemsForPath": [ 708 | "qFolderItems" 709 | ], 710 | "GetSupportedCodePages": [ 711 | "qCodePages" 712 | ], 713 | "GetCustomConnectors": [ 714 | "qConnectors" 715 | ], 716 | "GetStreamList": [ 717 | "qStreamList" 718 | ], 719 | "EngineVersion": [ 720 | "qVersion" 721 | ], 722 | "GetBaseBNF": [ 723 | "qBnfDefs", 724 | "qBnfHash" 725 | ], 726 | "GetBaseBNFHash": [ 727 | "qBnfHash" 728 | ], 729 | "GetBaseBNFString": [ 730 | "qBnfStr", 731 | "qBnfHash" 732 | ] 733 | } 734 | } --------------------------------------------------------------------------------