Angular Tutorial
Overview
This tutorial is designed to walk you through every aspect of io.Connect Browser - setting up a project, initializing a Main app, multiple Browser Client apps, and extending your apps with Shared Contexts, Interop, Window Management, Channels, App Management, Workspaces, and more io.Connect capabilities.
This guide uses Angular and its goal is to allow you to put to practice the basic concepts of io.Connect Browser in an Angular app by using the @interopio/ng library. It's strongly recommended to go through the JavaScript tutorial first in order to get a better understanding of io.Connect Browser without the added complexity of a web framework.
⚠️ Note that this tutorial uses Angular 20 with standalone components.
Introduction
You are a part of the IT department of a big multi-national bank and you have been tasked to lead the creation of a project which will be used by the Asset Management department of the bank. The project will consist of three apps:
- Clients - displays a full list of clients and details about them;
- Stocks - displays a full list of stocks with prices. When the user clicks on a stock, details about the selected stock should be displayed;
- Stock Details - displays details for a selected stock after the user clicks on a stock in the Stocks app;
All apps are being developed by different teams within the organizations and therefore are being hosted at different origins.
As an end result, the users want to be able to run the apps as Progressive Web Apps in separate windows in order to take advantage of their multi-monitor setups. Also, they want the apps, even though in separate windows, to be able to communicate with each other. For example, when a client is selected in the Clients app, the Stocks app should display only the stocks of the selected client.
Prerequisites
You must have a valid license key for io.Connect Browser.
This tutorial assumes that you are familiar with Angular and the concepts of JavaScript and asynchronous programming.
It's also recommended to have the Browser Platform, Browser Client and io.Connect Browser API Reference documentation available.
Each main chapter demonstrates a different io.Connect capability whose documentation you can find in the Capabilities section of the documentation.
Tutorial Structure
The tutorial code is located in the browser-tutorials GitHub repo with the following structure:
/angular
/solution
/start
/javascript
/solution
/start
/react
/solution
/start
/rest-server
| Directory | Description |
|---|---|
/javascript, /react, /angular |
Contain the starting files for the tutorials and also a full solution for each of them. |
/rest-server |
A simple server used in the tutorials to serve the necessary JSON data. |
1. Initial Setup
Clone the browser-tutorials GitHub repo to get the tutorial files.
1.1. Start Files
Next, go to the /angular/start directory which contains the starting files for the project. The tutorial examples assume that you will be working in the /start directory, but you can also move the files and work from another directory.
The /start directory contains the following:
| Directory | Description |
|---|---|
/client-details |
This is the Client Details app - a standalone Angular app scaffolded with the Angular CLI, without any custom settings except the "port" property in the angular.json file, which is set to 4101. |
/clients |
This is the Clients app - a standalone Angular app, scaffolded with the Angular CLI, without any custom settings. This will be the Main app of your io.Connect Browser project. |
/portfolio-downloader |
This is the Client Details app - a standalone Angular app scaffolded with the Angular CLI, without any custom settings except the "port" property in the angular.json file, which is set to 4102. |
/stocks |
This is the Stocks app - a standalone Angular app scaffolded with the Angular CLI, without any custom settings except the "port" property in the angular.json file, which is set to 4100. |
/workspace |
This is a Workspaces App, which will be used in the Workspaces chapter. |
All apps contain the following resources:
| Directory | Description |
|---|---|
/public |
Contains all static assets for each app such as icons and other files that will be available in the final build. |
/src |
Contains all standard files for a standalone Angular app. The /app directory of each app also contains an io-connect.service.ts file with methods for interaction with the io.Connect framework. |
The /app directory of the Stocks app contains views for the Stocks and the Stock Details apps and a data.service.ts file with methods for handling stock prices.
The Clients app contains a /plugins directory with Plugins that will be used in the Plugins chapter. The /app directory of the Clients app contains a data.service.ts file with methods for handling client entities.
Go to the directory of each app (including the Workspaces App), open a command prompt and run:
npm install
npm startThis will install the necessary dependencies and launch the apps on different ports:
| URL | App |
|---|---|
http://localhost:4100/ |
Stocks |
http://localhost:4101/ |
Client Details |
http://localhost:4102/ |
Portfolio Downloader |
http://localhost:4200/ |
Clients |
http://localhost:9300/ |
Workspaces App |
1.2. Solution Files
Before you continue, take a look at the solution files. You are free to use the solution as you like - you can check after each section to see how it solves the problem, or you can use it as a reference point in case you get stuck.
Go to the /rest-server directory and start the REST Server (as described in the REST Server chapter).
Go to the /angular/solution/clients/src/app/app.config.ts file of the Clients app and provide a valid license key for io.Connect Browser by using the licenseKey property of the of the config object.
Go to the directory of each app (including the Workspaces App), open a command prompt and run the following commands to install the necessary dependencies and start all apps:
npm install
npm startYou can now access the entry point of the project (the Clients app) at http://localhost:4200/clients.
1.3. REST Server
Before starting with the project, go to the /rest-server directory and start the REST server that will host the necessary data for the apps:
npm install
npm startThis will launch the server at port 8080.
2. Project Setup
2.1. Main App
Each io.Connect Browser project must have a single central app called Main app, or Browser Platform app. In a real-life scenario, this would be an app used for discovering and listing available apps, Workspaces, handling notifications, and much more. However, your goal now is to learn about all these aspects with as little complexity as possible. That's why the Clients app will serve as your Main app. The users will open the Clients app and from there they will be able to click on a client and see their stocks and so on.
Setting up a Main app is as simple as calling a function. First, install the @interopio/ng library. The library comes with the latest @interopio/browser and @interopio/browser-platform libraries, so you don't have to add any additional dependencies. The @interopio/browser-platform library handles the entire io.Connect environment, which is necessary for the Browser Client apps to be able to connect to the Main app and to each other.
Go to the Clients app and install the @interopio/ng library:
npm install @interopio/ngGo to the app.config.ts file of the Clients app and import the provideIoConnect() function from @interopio/ng and the IOBrowserPlatform() factory function from @interopio/browser-platform. Invoke the provideIoConnect() function inside the providers[] array and pass as an argument an object with settings for initializing the @interopio/ng library. Use the browserPlatform property of this object to provide the IOBrowserPlatform() factory function and a valid license key for io.Connect Browser:
import { provideIoConnect } from "@interopio/ng";
import IOBrowserPlatform, { IOConnectBrowserPlatform } from "@interopio/browser-platform";
// Settings for initializing the `@interopio/browser-platform` library.
const config: IOConnectBrowserPlatform.Config = {
// Provide a valid license key for io.Connect Browser.
licenseKey: "your-license-key"
};
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(),
// Settings for initializing the `@interopio/ng` library.
provideIoConnect({
browserPlatform: {
factory: IOBrowserPlatform,
config
}
}),
DataService,
IOConnectService
]
};⚠️ Note that you should import and invoke the
provideIoConnect()function only once in theApplicationConfigobject inside the root component of your Angular app.
This is everything you need to do to define your Angular app as a Main app.
2.2. Browser Clients
Now that you have a fully functional Main app, you must initialize the @interopio/browser library in the rest of the apps. This will allow them to connect to the Clients app and communicate with each other.
Go to the app.config.ts files of the rest of the apps and import the provideIoConnect() function from @interopio/ng and the IOBrowser() factory function from @interopio/browser. Invoke the provideIoConnect() function inside the providers[] array and pass as an argument an object with settings for initializing the @interopio/ng library. Use the browser property of this object to provide the IOBrowser() factory function:
import { provideIoConnect } from "@interopio/ng";
import IOBrowser from "@interopio/browser";
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
// Settings for initializing the `@interopio/ng` library.
provideIoConnect({
browser: {
factory: IOBrowser
}
}),
DataService,
IOConnectService
]
};The @interopio/browser library will be initialized when the apps are bootstrapped. In order to gain access to the io.Connect API and to any errors during the initialization, you must use the IOConnectStore service. You could inject IOConnectStore directly in your components, but a better practice is to define a service that will inject IOConnectStore, perform all specific operations you require, and expose only the necessary functionalities for your components.
2.3. IOConnectStore Service
The IOConnectStore service is an Angular service exposed by the @interopio/ng library that provides access to the io.Connect APIs.
All apps already contain an io-connect.service.ts file in the /src/app directory. This service is already set as a provider in the configuration of each app and is injected in the components. You will use this file to inject the IOConnectStore service and expose the necessary functionalities for each app.
The Main app of your project and all Browser Client apps have already been initialized with the respective io.Connect libraries. Now, you will inject the initialized io.Connect API object in the global window object and you will provide a visual indicator for the state of the io.Connect environment in case of initialization errors.
First, go to the io-connect.service.ts files of your Main app and all your Browser Client apps and import the IOConnectStore service. Define an IOConnectService class and pass the IOConnectStore service to its constructor as an argument:
// In `io-connect.service.ts` of all apps.
import { IOConnectStore } from "@interopio/ng";
export class IOConnectService {
constructor(private readonly ioConnectStore: IOConnectStore) {
// Injecting the initialized io.Connect API in the `window` object enables you
// to open the console and experiment with the io.Connect APIs.
(window as any).io = this.ioConnectStore.getIOConnect();
}
// You will use this class to expose all necessary functionalities for your app.
}Next, define a public getter named connectionStatus(). Use the getInitError() method of the IOConnectStore service to determine whether the initialization of the io.Connect library has been successful. In case of errors, return "unavailable". If the library initialization has been successful, return "available":
// In `io-connect.service.ts` of all apps.
import { IOConnectStore } from "@interopio/ng";
export class IOConnectService {
public get connectionStatus(): IOConnectStatus {
return this.ioConnectStore.getInitError() ? "unavailable" : "available";
}
}Next, go to the app.ts file of all apps (for the Stocks app, go to the stocks.ts and stock-details.ts files) and set the ioConnectStatus signal in the ngOnInit() hook by using the connectionStatus() method of the IOConnectService class:
// In `app.ts` of all apps and in `stocks.ts` and `stock-details.ts` for the Stocks app.
export class App {
public async ngOnInit(): Promise<void> {
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
}
}⚠️ Note that when you refresh the Browser Client apps, you will see that the io.Connect initialization is unsuccessful. This is because the Browser Client apps can't currently connect to the io.Connect environment provided by the Main app and therefore can't discover the Main app or each other. To be able to initialize the
@interopio/browserlibrary, all Browser Client apps must be started by the Main app or by another Browser Client app already connected to the io.Connect environment. Currently, the only way to open these apps is via the URL in the address bar of the browser. The next chapter will teach you how to open the Stocks app from the Clients app which will solve this problem.
To verify that the initializations are correct, open the browser console of the Clients app (press F12) and execute the following:
await io.windows.open("stocks", "http://localhost:4100/").catch(console.error);This will instruct the Clients app to open the Stocks app by using the Window Management API. The Stocks app will now be able to connect to the io.Connect environment and initialize the @interopio/browser library correctly. Repeat this for the rest of the apps by changing the values of the name and the url parameters when calling the open() method.
Next, you will begin to add io.Connect functionalities to the apps.
3. Window Management
The goal of this chapter is to start building the user flow of the entire project. The end users will open the Clients app and will be able to open the Stocks app by clicking on a client. Clicking on a stock in the Stocks app will open the Stock Details app.
Currently, the only way for the user to open the Stocks app is to manually enter its URL in the address bar. This, however, prevents the app from connecting to the io.Connect environment. Also, the users want the Stock Details app to open in a new window with specific dimensions and position. To achieve all this, you will use the Window Management API.
ℹ️ See also the Capabilities > Windows > Window Management documentation.
3.1. Opening Windows Dynamically
Instruct the Clients app to open the Stocks app in a new window when the user clicks on a client. Go to the io-connect.service.ts file of the Clients app and define a method that will open the Stocks app in a new window. open() method to open the Stocks app in a new window. The getNextCounter() method ensures that the name of each new Stocks instance will be unique:
export class IOConnectService {
public async openStocksWindow(): Promise<void> {
// The `name` and `url` parameters are required. The window name must be unique.
const name = `Stocks-${this.getNextCounter()}`;
const URL = "http://localhost:4100";
await this.ioConnectStore.getIOConnect().windows.open(name, URL);
}
}Next, go to the app.ts file of the Clients app and invoke the openStocksWindow() method in handleClientClick():
export class App {
public async handleClientClick(client: Client): Promise<void> {
this.ioConnectService.openStocksWindow();
}
}Clicking on a client will now open the Stocks app.
⚠️ Note that you must allow popups in the browser and remove any popup blockers to allow the Stocks window to open.
To complete the user flow, instruct the Stocks app to open a new window each time the user clicks on a stock. Remember that each io.Connect Window must have a unique name. To avoid errors resulting from attempting to open io.Connect Windows with conflicting names, check whether the clicked stock has already been opened in a new window.
Go to the io-connect.service.ts file of the Stocks app and define a method that will open the Stock Details app in a new window. The method will accept a stock object as a argument. Use the list() method to get a collection of all io.Connect Windows and check whether the clicked stock is already open in a window. It's safe to search by window name, because all io.Connect Window instances must have a unique name property:
export class IOConnectService {
public async openStockDetails(stock: Stock): Promise<void> {
const windowName = `${stock.BPOD} Details`;
const URL = "http://localhost:4100/details/";
// Check whether the clicked stock has already been opened in a new window.
const stockWindowExists = this.ioConnectStore.getIOConnect().windows.list().find(w => w.name === windowName);
if (stockWindowExists) {
return;
}
await this.ioConnectStore.getIOConnect().windows.open(windowName, URL);
}
}Next, go to the stocks.ts file and invoke the openStockDetails() method in handleStockClick(). Check whether io.Connect is available and open a new window. In case io.Connect is unavailable, preserve the original behavior:
export class Stocks {
public handleStockClick(stock: Stock): void {
const isConnected = this.ioConnectStatus() === "available";
if (isConnected) {
this.ioConnectService.openStockDetails(stock).catch(console.error);
return;
}
this.data.selectedStock = stock;
this.router.navigate(["/details"]);
}
}⚠️ Note that you must allow popups in the browser and remove any popup blockers to allow the Stock Details window to open.
3.2. Window Settings
To position the new Stock Details window, extend the logic in the open() method by passing an optional Settings object containing specific values for the window size (width and height) and position (top and left):
export class IOConnectService {
public async openStockDetails(stock: Stock): Promise<void> {
const windowName = `${stock.BPOD} Details`;
const URL = "http://localhost:4100/details/";
// Optional configuration object for the newly opened window.
const windowSettings: IOConnectBrowser.Windows.Settings = {
width: 600,
height: 600,
left: 900
};
const stockWindowExists = this.ioConnectStore.getIOConnect().windows.list().find(w => w.name === windowName);
if (stockWindowExists) {
return;
}
await this.ioConnectStore.getIOConnect().windows.open(windowName, URL, windowSettings);
}
}3.3. Window Context
To allow the Stock Details app to display information about the selected stock, set the stock selected in the Stocks app as a context to the newly opened Stock Details window. The Stock Details window will then access its context and extract the necessary stock information.
Add a context property to the window configuration object and assign the stock object as its value:
export class IOConnectService {
public async openStockDetails(stock: Stock): Promise<void> {
const windowName = `${stock.BPOD} Details`;
const URL = "http://localhost:4100/details/";
// Optional object with settings for the new window.
const windowSettings: IOConnectBrowser.Windows.Settings = {
width: 600,
height: 600,
left: 900,
// Set the `stock` object as a context for the new window.
context: stock
};
const stockWindowExists = this.ioConnectStore.getIOConnect().windows.list().find(w => w.name === windowName);
if (stockWindowExists) {
return;
}
await this.ioConnectStore.getIOConnect().windows.open(windowName, URL, windowSettings);
}
}Next, extend the IOConnectService class of the Stocks app with a method that will allow the Stock Details app to retrieve the window context. Get a reference to the current window by using the my() method and get its context with the getContext() method of the WebWindow object:
export class IOConnectService {
public async getMyWindowContext(): Promise<Stock> {
const myWindow = this.ioConnectStore.getIOConnect().windows.my();
return myWindow?.getContext();
}
}Go to the stock-details.ts file and retrieve the window context if io.Connect is available. In case io.Connect is unavailable, preserve the original behavior:
export class StockDetails {
public async ngOnInit(): Promise<void> {
this.stock = this.dataService.selectedStock;
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus() === "available") {
this.stock = await this.ioConnectService.getMyWindowContext();
}
}
}Now, when you click on a stock in the Stocks app, the Stock Details app will open in a new window displaying information about the selected stock.
4. Interop
Now, you will use the Interop API to pass the portfolio of the selected client to the Stocks app and show only the stocks present in their portfolio.
ℹ️ See also the Capabilities > Data Sharing > Interop documentation.
4.1. Registering Interop Methods and Creating Streams
When a user clicks on a client, the Stocks app should show only the stocks owned by this client. You can achieve this by registering an Interop method in the Stocks app which, when invoked, will receive the portfolio of the selected client and re-render the stocks table. Also, the Stocks app will create an Interop stream to which it will push the new stock prices. Subscribers to the stream will get notified when new prices have been generated.
Go to the io-connect.service.ts file of the Stocks app and define a method that will register an Interop method called "SelectClient". The method will receive as an argument an object with a client property, which will contain the entire object describing the selected client. Use the register() method to register the Interop method and pass a method name and a callback for handling method invocations:
export class IOConnectService {
constructor(
private readonly ioConnectStore: IOConnectStore,
private readonly _zone: NgZone
) { }
public async registerClientSelect(): Promise<void> {
const methodName = "SelectClient";
const handler = (args: { client: Client }): void => {
this._zone.run(() => this.selectedClientSource.next(args.client))
};
// Register an Interop method by providing a name and a handler.
await this.ioConnectStore.getIOConnect().interop.register(methodName, handler);
}
}⚠️ Note that the
nextinvocation is wrapped inNgZone.run(), because the custom event is executed outside the Angular Zone and therefore won't trigger change detection unless explicitly run inside the zone.
Streams can be described as special Interop methods. Define a method for creating an Interop stream. Use the createStream() method to create a stream called "LivePrices". Inject the DataService service, subscribe to stock price updates, and push the new prices to the stream:
export class IOConnectService {
constructor(
private readonly ioConnectStore: IOConnectStore,
private _zone: NgZone,
private readonly dataService: DataService
) { }
public async createPriceStream(): Promise<void> {
const streamName = "LivePrices";
// Creating an Interop stream.
const priceStream = await this.ioConnectStore.getIOConnect().interop.createStream(streamName);
// Pushing data to the stream.
// It isn't necessary to wrap the callback in `NgZone.run()`, because `DataService` uses
// internally `setInterval()`, which by default triggers a change detection.
this.dataService.onStockPrices().subscribe((priceUpdate) => priceStream.push(priceUpdate));
}
}Next, go to the the ngOnInit() method in the stocks.ts file, invoke the methods for registering an Interop method and creating an Interop stream, and subscribe to the onClientSelected() Observable if io.Connect is available:
export class Stocks {
public async ngOnInit(): Promise<void> {
this.allStocks = await this.data.getStocks();
this.stocks = this.allStocks;
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus() === "available") {
// Registering the Interop method.
this.ioConnectService.registerClientSelect().catch(console.error);
// Creating the Interop stream.
this.ioConnectService.createPriceStream().catch(console.error);
}
// Subscribing for notifications when the selected client changes.
this.ioConnectService.onClientSelected().subscribe((client) => {
this.stocks = client.portfolio
? this.allStocks.filter((stock) => client.portfolio.includes(stock.RIC))
: this.allStocks;
});
// Subscribing for notifications when the selected stock changes.
this.data.onStockPrices().subscribe((update) => {
this.stocks.forEach((stock) => {
const matchingStock = update.stocks.find((stockUpdate) => stockUpdate.RIC === stock.RIC);
stock.Ask = matchingStock!.Ask;
stock.Bid = matchingStock!.Bid;
});
});
}
}In a real production app, you may need to unregister the Interop method and close the Interop stream in the ngOnDestroy() hook. This depends on your business case, but here it's safe to leave it as is. Also, the registerClientSelect() and createPriceStream() invocations aren't awaited, because in this particular case it isn't important when they will resolve, but this may be different in a real-life scenario.
4.2. Method Discovery
Go to the io-connect.service.ts file of the Clients app and define a method that will check whether the "SelectClient" Interop method has been registered (i.e., whether the Stocks app is running). Use the methods() method to check for a registered Interop method with the specified name:
export class IOConnectService {
public sendSelectedClient(client: Client): void {
const methodName = "SelectClient";
// Finding an Interop method by name.
const interopMethod = this.ioConnectStore.getIOConnect().interop.methods().find(method => method.name === methodName);
}
}4.3. Method Invocation
Next, invoke the Interop method if it has been registered.
Use the invoke() method and pass the name of the Interop method as a first argument and an object with a client property as a second:
export class IOConnectService {
// The `sendSelectedClient()` method becomes `async` because `invoke()` is asynchronous.
public async sendSelectedClient(client: Client): Promise<void> {
const methodName = "SelectClient";
const methodArgs = { client };
const interopMethod = this.ioConnectStore.getIOConnect().interop.methods().find(method => method.name === methodName);
if (interopMethod) {
// Invoking the Interop method by name and providing arguments for the invocation.
await this.ioConnectStore.getIOConnect().interop.invoke(methodName, methodArgs);
}
}
}4.4. Method Added Subscription
Next, you will instruct the Clients app to wait for the "SelectClient" Interop method in case it hasn't been registered yet. Use the methodAdded() method, which will be invoked each time an app registers an Interop method. Pass a handler that will check the name of the newly registered method and resolve only if it matches with the name of the desired method:
export class IOConnectService {
private waitForMethodAdded(methodName: string): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = this.ioConnectStore.getIOConnect().interop.methodAdded((method) => {
// Check the name of the added method.
if (method.name === methodName) {
// Unsubscribe from the event and resolve the `Promise` when the desired method is available.
unsubscribe();
resolve();
}
});
});
}
}Extend the sendSelectedClient() method to wait for the "SelectClient" Interop method to be registered:
export class IOConnectService {
public async sendSelectedClient(client: Client): Promise<void> {
const methodName = "SelectClient";
const methodArgs = { client };
const interopMethod = this.ioConnectStore.getIOConnect().interop.methods().find(method => method.name === methodName);
if (!interopMethod) {
// Wait for the Interop method to be registered.
await this.waitForMethodAdded(methodName);
}
await this.ioConnectStore.getIOConnect().interop.invoke(methodName, methodArgs);
}
}Go to the app.ts file of the Clients app and invoke the sendSelectedClient() method in handleClientClick():
export class App {
public async handleClientClick(client: Client): Promise<void> {
await this.ioConnectService.openStocksWindow();
await this.ioConnectService.sendSelectedClient(client);
}
}Now, when you click on a client in the Clients app, the Stocks app will display only the stocks that are in the portfolio of the selected client.
4.5. Stream Subscription
Go to the io-connect.service.ts file of the Stocks app and define a method that will receive the selected stock as an argument and will subscribe to the Interop stream. Use the subscribe() method to subscribe to the "LivePrices" stream and use the onData() method of the returned subscription object to assign a handler for the received stream data.
As in the previous section, use the methodAdded() method to instruct the Stocks app to wait for the "LivePrices" Interop stream in case it hasn't been registered yet:
export class IOConnectService {
public async subscribeToLivePrices(stock: Stock): Promise<IOConnectBrowser.Interop.Subscription> {
const methodName = "LivePrices";
// Interop streams are special Interop methods that have a `supportsStreaming` property set to `true`.
// To find the desired stream, filter the existing methods by name and by the `supportsStreaming` property.
// If the stream hasn't been created yet, await `waitForMethodAdded()` which will
// resolve with the `MethodDefinition` object describing the Interop stream.
const streamMethod = this.ioConnectStore.getIOConnect().interop.methods().find((method) => method.name === methodName && method.supportsStreaming)
?? (await this.waitForMethodAdded(methodName, true));
// Creating a stream subscription.
const subscription = await this.ioConnectStore.getIOConnect().interop.subscribe(streamMethod);
// Use the `onData()` method of the `subscription` object to define a handler for the received stream data.
subscription.onData((streamData) => {
const newPrices: StockPriceUpdate[] = streamData.data.stocks;
// Extract only the stock you are interested in.
const selectedStockPrice = newPrices.find((prices) => prices.RIC === stock.RIC);
if (!selectedStockPrice) {
return;
}
this._zone.run(() => this.priceUpdateSource.next({ Ask: Number(selectedStockPrice.Ask), Bid: Number(selectedStockPrice.Bid) }));
});
return subscription;
}
private waitForMethodAdded(methodName: string, supportsStreaming: boolean = false): Promise<IOConnectBrowser.Interop.MethodDefinition> {
return new Promise<IOConnectBrowser.Interop.MethodDefinition>((resolve) => {
const unsubscribe = this.ioConnectStore.getIOConnect().interop.methodAdded((method) => {
// Check the method name and whether it's an Interop stream.
if (method.name === methodName && method.supportsStreaming === supportsStreaming) {
// Unsubscribe from the event and resolve the `Promise` when the desired method is available.
unsubscribe();
resolve(method);
}
});
});
}
}Go to the stock-details.ts file of the Stock Details app and subscribe to the "LivePrices" Interop stream if io.Connect is available. Uncomment the ioConnectSubscription property, subscribe to the onPriceUpdate() Observable, and handle the new prices:
export class StockDetails {
public async ngOnInit(): Promise<void> {
this.stock = this.dataService.selectedStock;
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus() === "available") {
this.stock = await this.ioConnectService.getMyWindowContext();
this.ioConnectSubscription = await this.ioConnectService.subscribeToLivePrices(this.stock);
}
this.ioConnectService.onPriceUpdate().subscribe((newPrices) => {
this.stock!.Ask = newPrices.Ask;
this.stock!.Bid = newPrices.Bid;
});
}
}Define an ngOnDestroy() method and close the stream subscription if it exists:
public ngOnDestroy(): void {
// Closing the stream subscription.
this.ioConnectSubscription?.close();
}Now, the Stocks Details will display live price updates for the selected stock.
5. Shared Contexts
The next request of the users is to be able to see in the Stock Details app whether the selected client has the selected stock in their portfolio. This time you will use the Shared Contexts API to connect the Clients, Stocks, and Stock Details apps by using shared context objects.
ℹ️ See also the Capabilities > Data Sharing > Shared Contexts documentation.
5.1. Updating a Context
Go to the io-connect.service.ts file of the Clients app and comment out or delete the existing code that uses the Interop API. Use the update() method to create and set a shared context object by providing a name for the context - "SelectedClient", and value - the client object describing the currently selected client. Other apps will be able to subscribe for updates to this context and be notified when its value changes:
export class IOConnectService {
public async sendSelectedClient(client: Client): Promise<void> {
// The `update()` method updates the value of a specified context object.
// If the specified context doesn't exist, it will be created.
await this.ioConnectStore.getIOConnect().contexts.update("SelectedClient", client);
}
}5.2. Subscribing for Context Updates
Next, go to the io-connect.service.ts file of the Stocks app and define a method the will subscribe to the shared context. Use the subscribe() method to subscribe for updates to the "SelectedClient" context object:
export class IOConnectService {
public async subscribeToSharedContext() {
// Subscribing for updates to a shared context by specifying context name and providing a handler for the updates.
this.ioConnectStore.getIOConnect().contexts.subscribe("SelectedClient", (client) => {
this._zone.run(() => this.selectedClientSource.next(client));
});
}
}Go to the stocks.ts file of the Stocks app and comment out or delete the invocation of the registerClientSelect() method. Invoke subscribeToSharedContext() instead:
export class Stocks {
public async ngOnInit(): Promise<void> {
if (this.ioConnectStatus() === "available") {
// Subscribe for updates of the shared context.
this.ioConnectService.subscribeToSharedContext().catch(console.error);
}
}
}Go to the stock-details.ts file of the Stock Details app, invoke the subscribeToSharedContext() method, and subscribe to the onClientSelected() Observable. When a new client has been selected, check whether the client has the current stock in their portfolio and set the clientMessage property to the appropriate value:
export class StockDetails {
public async ngOnInit(): Promise<void> {
this.stock = this.dataService.selectedStock;
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus() === "available") {
this.stock = await this.ioConnectService.getMyWindowContext();
this.ioConnectSubscription = await this.ioConnectService.subscribeToLivePrices(this.stock);
// Subscribe for updates of the shared context.
this.ioConnectService.subscribeToSharedContext().catch(console.error);
}
this.ioConnectService.onClientSelected().subscribe((client) => {
// Check whether the selected client has the current stock in their portfolio.
this.clientMessage = client.portfolio.includes(this.stock!.RIC)
? `${client.name} has this stock in their portfolio`
: `${client.name} does NOT have this stock in their portfolio`;
});
this.ioConnectService.onPriceUpdate().subscribe((newPrices) => {
this.stock!.Ask = newPrices.Ask;
this.stock!.Bid = newPrices.Bid;
});
}
}Now, the Stock Details app will show whether the client selected from the Clients app has the the displayed stock in their portfolio.
6. Channels
The latest requirement from the users is to be able to work with multiple clients at a time by having multiple instances of the Stocks app show the portfolios of different clients. Currently, no matter how many instances of the Stocks app are running, they are all listening for updates to the same context and therefore all show information about the same selected client. Here you will use the Channels API to allow each instance of the Stocks app to subscribe for updates to the context of a different Channel. The different Channels are color-coded and the user will be able to select a Channel from a Channel Selector UI. The Clients app will update the context of the currently selected Channel when the user clicks on a client.
ℹ️ See also the Capabilities > Data Sharing > Channels documentation.
6.1. Channels Configuration
The Main app (the Clients app in this project) handles the configuration of the io.Connect environment. The IOBrowserPlatform() factory function accepts an optional configuration object that allows you to enable, disable, and configure various io.Connect features. Here, you will use it to define the available Channels.
Go the the app.config.ts file of the Clients app, define Channels, and extend the configuration for the internal initialization of the @interopio/browser-platform library:
// Channel definitions.
const channels: IOConnectBrowserPlatform.Channels.Config = {
definitions: [
{
name: "Red",
meta: {
color: "red"
}
},
{
name: "Green",
meta: {
color: "green"
}
},
{
name: "Blue",
meta: {
color: "#66ABFF"
}
},
{
name: "Pink",
meta: {
color: "#F328BB"
}
},
{
name: "Yellow",
meta: {
color: "#FFE733"
}
},
{
name: "Dark Yellow",
meta: {
color: "#b09b00"
}
},
{
name: "Orange",
meta: {
color: "#fa5a28"
}
},
{
name: "Purple",
meta: {
color: "#c873ff"
}
},
{
name: "Lime",
meta: {
color: "#8af59e"
}
},
{
name: "Cyan",
meta: {
color: "#80f3ff"
}
}
]
};
// Settings for the internal initialization of the `@interopio/browser-platform` library.
const config: IOConnectBrowserPlatform.Config = {
licenseKey: "your-license-key",
channels
};6.2. Channel Selector Widget
The io.Connect widget provides a floating UI element that allows users to select a Channel for the current window. This enables each instance of your app to subscribe for updates of a specific Channel, allowing the end users to work with multiple clients simultaneously.
Go to the app.config.ts file of the Clients app and extend the configuration for the internal initialization of the @interopio/browser-platform library. Use the widget property to define the widget sources and the browser property to enable the widget for the Clients app.
The REST server for the tutorial provides all required static files for the widget (bundle, styles, and fonts):
// Define the widget sources.
const widget: IOConnectBrowserPlatform.Widget.Config = {
// It's required to specify the locations of the bundle, styles and fonts for the widget.
sources: {
bundle: "http://localhost:8080/static/widget/io-browser-widget.es.js",
styles: ["http://localhost:8080/static/widget/styles.css"],
fonts: ["http://localhost:8080/static/widget/fonts.css"]
}
};
// Enabling the widget for the Clients app.
const browser: IOConnectBrowser.Config = {
widget: {
enable: true
}
};
// Settings for the internal initialization of the `@interopio/browser-platform` library.
const config: IOConnectBrowserPlatform.Config = {
licenseKey: "your-license-key",
channels,
widget,
browser
};Next, go to the app.config.ts file of the Stocks app, define a [Config](../../reference/javascript/io.connect browser/index.html#Config) object for initializing the @interopio/browser library, and enable the widget:
import IOBrowser, { IOConnectBrowser } from "@interopio/browser";
// Settings for the internal initialization of the `@interopio/browser` library.
const config: IOConnectBrowser.Config = {
widget: {
enable: true
}
};
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
provideIoConnect({
browser: {
factory: IOBrowser,
// Pass the configuration object.
config
}
}),
DataService,
IOConnectService
]
};Now, the Clients and the Stocks app will display the io.Connect widget, allowing the users to select a Channel for each window.
6.3. Publishing and Subscribing
Next, enable the Clients app to publish updates to the current Channel context and the Stocks app to subscribe for these updates.
Go to the io-connect.service.ts file of the Clients app and extend the sendSelectedClient() method to publish updates to the current Channel. Use the publish() method and pass the selected client as an argument to update the Channel context when a new client is selected. The publish() method will throw an error if the app tries to publish data but isn't on a Channel. Use the getMy() method to check for the current Channel:
export class IOConnectService {
public async sendSelectedClient(client: Client): Promise<void> {
await this.ioConnectStore.getIOConnect().contexts.update("SelectedClient", client);
// Retrieve the current Channel.
const currentChannel = await this.ioConnectStore.getIOConnect().channels.getMy();
// Publish to the current Channel if joined to one.
if (currentChannel) {
await this.ioConnectStore.getIOConnect().channels.publish(client);
}
}
}Don't remove the logic for updating the shared context object, because the Stock Details app still uses the shared context to retrieve the client information.
Next, go to the io-connect.service.ts file of the Stocks app and define a method that will subscribe for Channel updates:
export class IOConnectService {
public subscribeToChannelContext(): void {
// Subscribing for updates to the current Channel and providing a handler for the updates.
this.ioConnectStore.getIOConnect().channels.subscribe((client) => {
this._zone.run(() => this.selectedClientSource.next(client));
});
}
}Go to the stocks.ts file of the Stocks app, comment out or delete the invocation of subscribeToSharedContext(), and invoke subscribeToChannelContext() instead:
export class Stocks {
public async ngOnInit(): Promise<void> {
this.allStocks = await this.data.getStocks();
this.stocks = this.allStocks;
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus === "available") {
// Subscribe for Channel updates.
this.ioConnectService.subscribeToChannelContext();
this.ioConnectService.createPriceStream().catch(console.error);
}
this.ioConnectService.onClientSelected().subscribe((client) => {
// Check whether the client portfolio exists, because when the app joins a Channel,
// it will always receive the initial Channel context, which might be an empty object.
// In this case, the Stocks app will display all stocks.
this.stocks = client.portfolio
? this.allStocks.filter((stock) => client.portfolio.includes(stock.RIC))
: this.allStocks;
});
this.data.onStockPrices().subscribe((update) => {
this.stocks.forEach((stock) => {
const matchingStock = update.stocks.find((stockUpdate) => stockUpdate.RIC === stock.RIC);
stock.Ask = matchingStock!.Ask;
stock.Bid = matchingStock!.Bid;
});
});
}
}Now, when the Clients and the Stocks apps are on the same Channel, the Stocks app will be updated with the portfolio of the selected client.
7. App Management
Up until now, you had to use the Window Management API to open new windows when the user clicks on a client in the Clients app or on a stock in the Stocks app. This works fine for small projects, but doesn't scale well for larger ones, because this way each app must know all details (URL, start position, initial context, etc.) of every app it starts. In this chapter, you will replace the Window Management API with the App Management API which will allow you to predefine all available apps when initializing the Main app. The Clients app will be decoupled from the Stocks app and the Stocks app will be decoupled from Stock Details - you will need only the names of the apps to be able to start them.
ℹ️ See also the Capabilities > App Management documentation.
7.1. App Configuration
To take advantage of the App Management API, define configurations for your apps. Go to the app.config.ts in Clients app and define an applications object containing all required definitions. Pass the applications object to the configuration object for the internal initialization of the @interopio/browser-platform library:
// App definitions.
const applications: IOConnectBrowserPlatform.Applications.Config = {
local: [
{
name: "Stocks",
type: "window",
details: {
url: "http://localhost:4100/",
left: 0,
top: 0,
width: 860,
height: 600
}
},
{
name: "Stock Details",
type: "window",
details: {
url: "http://localhost:4100/details",
left: 100,
top: 100,
width: 400,
height: 400
}
}
]
};
const config: IOConnectBrowserPlatform.Config = {
licenseKey: "your-license-key",
channels,
widget,
applications,
browser
};The name and url properties are required when defining an app. As you see, the position and size of the app windows is now defined in their configuration.
7.2. Starting Apps
Go to the app.ts file of the Clients app and remove the code in handleClientClick() that uses the Window Management API (including the code related to getNextCounter() in the io-connect.service.ts file, as it won't be necessary to create unique window names).
Go to the io-connect.service.ts file of the Clients app and define a method for starting the Stocks app. Get the Stocks app object with the application() method and use its start() method to start the Stocks app when the user clicks on a client. Pass an ApplicationStartOptions object as a second argument to the start() method and use the channelId property to specify the name of the current Channel:
export class IOConnectService {
public async startStocksApp(): Promise<void> {
// Retrieve the Stocks app object.
const stocksApp = this.ioConnectStore.getIOConnect().appManager.application("Stocks");
if (!stocksApp) {
return;
}
const currentChannel = await this.ioConnectStore.getIOConnect().channels.getMy();
// Start the Stocks app and pass the name of the current Channel.
await stocksApp.start(undefined, { channelId: currentChannel?.name }).catch(console.error);
}
}Go to the app.ts file of the Clients app and update handleClientClick() to use the startStocksApp() method:
export class App {
public async handleClientClick(client: Client): Promise<void> {
await this.ioConnectService.startStocksApp();
await this.ioConnectService.sendSelectedClient(client);
}
}Now, when the user clicks on a client in the Clients app, an instance of the Stocks app will open and it will join the same Channel to which the Clients app is joined (if any).
7.3. App Instances
Go to the io-connect.service.ts file of the Stocks app and define a method for starting the Stock Details app. Use the application() method to get the Stock Details app object. Check whether an instance with the selected stock has already been started by iterating over the contexts of the existing Stock Details instances. If there is no instance with the selected stock, call the start() method on the app object. Pass the selected stock as a context for the started instance. Provide ApplicationStartOptions as a second argument to the start() method in order to set the bounds and the Channel of the started instance:
export class IOConnectService {
public async startStockDetails(stock: Stock): Promise<void> {
const detailsApplication = this.ioConnectStore.getIOConnect().appManager.application("Stock Details");
if (!detailsApplication) {
return;
}
// Check whether an instance with the selected stock is already running.
const contexts = await Promise.all(
// Use the `instances` property to get all running app instances.
detailsApplication.instances.map((instance) => instance.getContext())
);
const isRunning = contexts.find((context: any) => context?.RIC === stock.RIC);
if (isRunning) {
return;
}
const currentChannel = await this.ioConnectStore.getIOConnect().channels.getMy();
const startOptions: IOConnectBrowser.AppManager.ApplicationStartOptions =
{
width: 600,
height: 600,
left: 900,
top: 0,
channelId: currentChannel?.name
};
// Start the Stock Details app and pass the selected stock as context and the current Channel as start options.
await detailsApplication.start(stock, startOptions);
}
}Go to the stock.ts file of the Stocks app and replace the openStockDetails() method with the startStockDetails() method in handleStockClick():
export class Stocks {
public handleStockClick(stock: Stock): void {
const isConnected = this.ioConnectStatus() === "available";
if (isConnected) {
this.ioConnectService.startStockDetails(stock).catch(console.error);
return;
}
this.data.selectedStock = stock;
this.router.navigate(["/details"]);
}
}Go to the io-connect.service.ts file of the Stocks app and define a method that will be used by the Stock Details app for retrieving the context of the current app instance via the App Management API:
export class IOConnectService {
public async getMyAppInstanceContext(): Promise<Stock> {
// Retrieving the current app instance.
const myInstance = this.ioConnectStore.getIOConnect().appManager.myInstance;
// Retrieving the context of the current app instance.
return myInstance.getContext();
}
}Go to the stock-details.ts file of the Stock Details app and replace the getMyWindowContext() method with the getMyAppInstanceContext() method in ngOnInit():
export class StockDetails {
public async ngOnInit(): Promise<void> {
if (this.ioConnectStatus() === "available") {
this.stock = await this.ioConnectService.getMyAppInstanceContext();
}
}
}Everything works as before, the difference being that the apps now use the App Management API instead of the Window Management API.
8. Plugins
The developer team has decided against hard coding app definitions, as in practice it's more scalable to fetch them from a web service. The Plugins allow you to execute initial system logic contained in a custom function with access to the io object. You can also configure the Main app whether to wait for the execution of the Plugin to complete before initialization. This will enable you to fetch and import the app definitions on startup of the Main app, but before the initialization of the @interopio/browser-platform library has completed, so that they are available to the io.Connect framework when the user starts the Main app.
ℹ️ See also the Capabilities > Plugins documentation.
8.1. Defining a Plugin
Go to the app.config.ts file of the Clients app, comment out or delete the previously declared app definitions and remove the applications property from the library configuration object.
Import the setupApplications() function from the applicationsPlugin.ts file located in the /plugins folder of the Clients app.
Next, configure the Plugin in the Main app by using the plugins property of the @interopio/browser-platform configuration object. Plugins are defined in the definitions array of the plugins object. Set a name for the Plugin and pass a reference to the setupApplications() function in the start property of the Plugin object. Use the optional config object to pass the URL from which to fetch the app definitions. Set the critical property to true to instruct the Main app to wait for the Plugin to execute before the platform initialization completes:
import { setupApplications } from "../plugins/applicationsPlugin";
// Define a Plugin.
const plugins: IOConnectBrowserPlatform.Plugins.Config = {
definitions: [
{
name: "Setup Applications",
// The REST server provides the app definitions.
config: { url: "http://localhost:8080/api/applicationsAngular"},
start: setupApplications,
critical: true
}
]
};
// Remove the `applications` property and add `plugins`.
const config: IOConnectBrowserPlatform.Config = {
licenseKey: "your-license-key",
channels,
widget,
browser,
plugins
};8.2. Implementing a Plugin
Go to the applicationsPlugin.ts file of the Clients app. The setupApplications() function will be the Plugin that will be executed on startup of the Main app. It will receive an initialized io object as a first argument and the config object from the Plugin definition as a second argument. Extract the URL from which to fetch the app definitions by using the url property of the config object.
In setupApplications(), call the fetchAppDefinitions() function and pass to it the URL as an argument. Store the fetched app definitions in a variable and use the import() method of the inMemory object of the App Management API to import the app definitions dynamically:
export const setupApplications = async (io: IOConnectBrowser.API, config: { url: string }) => {
try {
const appDefinitions = await fetchAppDefinitions(config.url);
await io.appManager.inMemory.import(appDefinitions);
} catch (error) {
console.error(JSON.stringify(error));
}
};From a user perspective, everything works as before, but by using a Plugin to fetch and import the app definitions dynamically, you have decoupled the Main app from the previously hard coded applications object.
9. Workspaces
The latest feedback from the users is that their desktops become cluttered very quickly with multiple floating windows. The io.Connect Browser Workspaces feature solves exactly that problem.
The new requirement is that when a user clicks on a client in the Clients app, a new Workspace is to open displaying detailed information about the selected client in one app and their stocks portfolio in another. When the user clicks on a stock, a third app is to appear in the same Workspace displaying more details about the selected stock. You will use the Client Details app for displaying information about the selected client.
Remove all logic and references related to Channels from the Clients and Stocks apps that were introduced in a previous chapter (including the Widget initialization).
Instead, you will use Workspaces to allow the users to work with multiple clients at once and organize their desktops at the same time. Channels and Workspaces can, of course, be used together to provide extremely enhanced user experience, but in order to focus entirely on working with Workspaces and the Workspaces API, the Channels functionality will be ignored.
ℹ️ See also the Capabilities > Windows > Workspaces documentation.
9.1. Setup
All Workspaces are contained in a specialized standalone web app called Workspaces App. It's outside the scope of this tutorial to cover building and customizing this app, so you have a ready-to-go app located at /workspace. The Workspaces App is already being hosted at http://localhost:9300/.
The Client Details app that will be used in this chapter has already been set up in the 1. Initial Setup and the 2.2. Browser Clients sections of the tutorial and is hosted at http://localhost:4101.
9.2. Workspace Layouts
A Workspace Layout describes the apps participating in the Workspace and their arrangement. In a real-life scenario, Workspace Layouts, like app definitions, will most likely be fetched from a web service. Therefore, you can use another Plugin to fetch a Workspace Layout named "Client Space" that the Clients app will use as a blueprint for restoring a Workspace when the user clicks on a client.
ℹ️ For more details on using Plugins, see Chapter 8..
Go to the app.config.ts file of the Clients app, import the setupLayouts() function from the layoutsPlugin.ts file located in the /plugins folder, and define another Plugin that will fetch the Workspace Layout:
import { setupLayouts } from "../plugins/layoutsPlugin";
const plugins: IOConnectBrowserPlatform.Plugins.Config = {
definitions: [
{
name: "Setup Applications",
config: { url: "http://localhost:8080/api/applicationsAngular"},
start: setupApplications,
critical: true
},
{
name: "Setup Workspace Layouts",
config: { url: "http://localhost:8080/api/layouts"},
start: setupLayouts,
critical: true
}
]
};Go to the layoutsPlugin.ts file. In setupLayouts(), call the fetchWorkspaceLayoutDefinitions() function and pass to it the URL as an argument. Store the fetched Layout definitions in a variable and use the import() method of the Layouts API to import the Workspace Layout dynamically:
export const setupLayouts = async (io: IOConnectBrowser.API, config: { url: string }) => {
try {
const layoutDefinitions = await fetchWorkspaceLayoutDefinitions(config.url);
await io.layouts.import(layoutDefinitions);
} catch (error) {
console.error(JSON.stringify(error));
}
};Now, the Workspace Layout can be restored by name via the Workspaces API.
9.3. Initializing Workspaces
To be able to use Workspaces functionalities, initialize the Workspaces API in the Clients, Client Details and Stocks apps. The Stock Details app will participate in the Workspace, but won't use any Workspaces functionality.
Go to the root directories of the Clients, Stocks, and Client Details apps and run the following command to install the Workspaces library:
npm install @interopio/workspaces-apiGo to the app.config.ts file of the Clients app and add the necessary configuration for initializing the Workspaces library. Provide the IOWorkspaces() factory function and the location of the Workspaces App:
import IOWorkspaces from "@interopio/workspaces-api";
// Provide the location of the Workspaces App.
const workspaces: IOConnectBrowserPlatform.Workspaces.Config = {
src: "http://localhost:9300/"
};
// Provide the factory function for initializing the `@interopio/workspace-api` library.
const browserConfig: IOConnectBrowser.Config = {
libraries: [IOWorkspaces]
};
const config: IOConnectBrowserPlatform.Config = {
licenseKey: "your-license-key",
channels,
plugins,
workspaces,
browser
};Next, go to the app.config.ts files of the Stocks and Client Details apps, import and provide the IOWorkspaces() factory function to the libraries array of the configuration object for initializing the @interopio/browser library:
import IOWorkspaces from "@interopio/workspaces-api";
const config: IOConnectBrowser.Config = {
libraries: [IOWorkspaces]
};
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideIoConnect({
browser: {
factory: IOBrowser,
config
}
}),
IOConnectService
]
};9.4. Opening Workspaces
Next, implement opening a new Workspace when the user clicks on a client in the Clients app.
Go to the io-connect.service.ts file of the Clients app, define a function that will restore by name the Workspace Layout you retrieved earlier, and pass the selected client as a starting context. The specified context will be attached as window context to all windows participating in the Workspace:
export class IOConnectService {
public async restoreWorkspace(client: Client): Promise<void> {
try {
const workspace = await this.ioConnectStore.getIOConnect().workspaces?.restoreWorkspace("Client Space", { context: client });
} catch (error: any) {
console.error(JSON.stringify(error));
}
}
}Go to the app.ts file of the Clients app and update handleClientClick() to invoke only the restoreWorkspace() method:
export class App {
public async handleClientClick(client: Client): Promise<void> {
await this.ioConnectService.restoreWorkspace(client);
}
}Now, a new Workspace will open every time the user clicks on a client in the Clients app.
9.5. Starting Context
Handle the starting Workspace context to show the details and the portfolio of the selected client in the Client Details and the Stocks apps. Also, set the Workspace title to the name of the selected client.
Go to the io-connect.service.ts file of the Client Details app and define a function that will be used for handling the details of the selected client. Use the onContextUpdated() method of the current Workspace to subscribe for context updates. Set the selected client value to the updated context and set the title of the Workspace to the name of the selected client:
export class IOConnectService {
public async subscribeToWorkspaceContextUpdate(): Promise<void> {
const myWorkspace = await this.ioConnectStore.getIOConnect().workspaces?.getMyWorkspace();
if (!myWorkspace) {
return;
}
myWorkspace.onContextUpdated((newContext: Client) => {
this._zone.run(() => this.selectedClientSource.next(newContext));
myWorkspace.setTitle(newContext.name);
});
}
}Go to the app.ts file of the Client Details app and invoke the subscribeToWorkspaceContextUpdate() method in ngOnInit():
export class App {
public async ngOnInit(): Promise<void> {
this.ioConnectStatus = this.ioConnectService.connectionStatus;
if (this.ioConnectStatus() === "available") {
this.ioConnectService.subscribeToWorkspaceContextUpdate();
}
this.ioConnectService.onClientSelected().subscribe((client) => {
this.client = client;
});
}
}Next, go to the io-connect.service.ts file of the Stocks app and define a function that will be used for handling the stocks of the selected client. Use the onContextUpdated() Workspace method and set up the stocks for the selected client:
export class IOConnectService {
public async setClientFromWorkspace(): Promise<void> {
const myWorkspace = await this.ioConnectStore.getIOConnect().workspaces?.getMyWorkspace();
if (!myWorkspace) {
return;
}
myWorkspace.onContextUpdated((newContext: Client) => {
this._zone.run(() => this.selectedClientSource.next(newContext));
});
}
}Go to the stocks.ts file of the Stocks app and invoke the setClientFromWorkspace() method in ngOnInit():
export class Stocks {
public async ngOnInit(): Promise<void> {
this.allStocks = await this.data.getStocks();
this.stocks = this.allStocks;
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus() === "available") {
this.ioConnectService.createPriceStream().catch(console.log);
// Subscribe for Workspace context updates.
this.ioConnectService.setClientFromWorkspace().catch(console.log);
}
this.ioConnectService.onClientSelected().subscribe((client) => {
this.stocks = this.allStocks.filter(stock => client.portfolio.includes(stock.RIC));
});
this.data.onStockPrices().subscribe((update) => {
this.stocks.forEach((stock) => {
const matchingStock = update.stocks.find((stockUpdate) => stockUpdate.RIC === stock.RIC);
stock.Ask = matchingStock!.Ask;
stock.Bid = matchingStock!.Bid;
});
});
}
}Now, when you select a client in the Clients app, a new Workspace will open with the Client Details and the Stocks apps showing the relevant client information.
9.6. Modifying Workspaces
Next, you have to make the Stock Details app appear in the same Workspace as a sibling of the Stocks app when the user clicks on a stock. You have to check whether the Stock Details app has already been added to the Workspace, and if not - add it and update its context with the selected stock, otherwise - only update its context.
ℹ️ To achieve this functionality, you will have to manipulate a Workspace and its elements. It's recommended that you familiarize yourself with the Workspaces terminology to fully understand the following concepts and steps. Use the available documentation about Workspaces Concepts, Workspace Box Elements, and the Workspaces API.
The Stocks app is a WorkspaceWindow that is the only child of a Group element. If you add the Stock Details app as a child to that Group, it will be added as a second tab window and the user will have to manually switch between both apps. The Stock Details app has to be a sibling of the Stocks app, but both apps have to be visible within the same parent element. That's why, you have to add a new Group element as a sibling of the existing Group that contains the Stocks app, and then load the Stock Details app in it.
After the Stocks Details app has been opened in the Workspace as a WorkspaceWindow, you have to pass the selected stock as its context. To do that, get a reference to the underlying WebWindow object of the Stock Details window by using the getGdWindow() method of the WorkspaceWindow instance and update its context with the updateContext() method.
Go to the io-connect.service.ts file of the Stocks app and define the following function:
export class IOConnectService {
public async openStockDetailsInWorkspace(stock: Stock): Promise<void> {
const myWorkspace = await this.ioConnectStore.getIOConnect().workspaces?.getMyWorkspace();
if (!myWorkspace) {
return;
}
// Reference to the `WorkspaceWindow` object of the Stock Details instance.
let detailsWorkspaceWindow = myWorkspace.getWindow((window) => window.appName === "Stock Details");
// Check whether Stock Details has already been opened.
if (detailsWorkspaceWindow) {
// Update the context of the Stock Details window.
return detailsWorkspaceWindow.getGdWindow().updateContext(stock);
}
// Reference to the current window.
const myId = this.ioConnectStore.getIOConnect().windows.my()?.id;
// Reference to the immediate parent element of the Stocks window.
const myImmediateParent = myWorkspace.getWindow((window) => window.id === myId).parent;
if (!myImmediateParent) {
return;
}
// Add a `Group` element as a sibling of the immediate parent of the Stocks window.
const group = await (myImmediateParent as IOConnectWorkspaces.WorkspaceBox).parent.addGroup();
// Open the Stock Details window in the newly created `Group` element.
detailsWorkspaceWindow = await group.addWindow({ appName: "Stock Details" });
await detailsWorkspaceWindow.forceLoad();
await detailsWorkspaceWindow.getGdWindow().updateContext(stock);
}
}⚠️ Note that
forceLoad()is used to make sure that the Stock Details app is loaded and an io.Connect Window instance is available. This is necessary, becauseaddWindow()adds a new window to theGroup(meaning that it exists as an element in the Workspace), but it doesn't guarantee that the content has loaded.
Go to the stocks.ts file of the Stocks app and invoke the openStockDetailsInWorkspace() method in handleStockClick():
export class Stocks {
public handleStockClick(stock: Stock): void {
const isConnected = this.ioConnectStatus() === "available";
if (isConnected) {
this.ioConnectService.openStockDetailsInWorkspace(stock);
return
}
this.data.selectedStock = stock;
this.router.navigate(['/details']);
}
}Now, when the user clicks on a stock in the Stocks app, the window context of the Stock Details app will be updated. To detect the changes in the Stock Details app, go to the io-connect.service.ts file of the Stocks app and use the onContextUpdated() method of the WebWindow instance to subscribe for context updates. Since the stock will change dynamically, you must also set the BehaviorSubject of the selectedStockSource() Observable:
export class IOConnectService {
public setSelectedStock(newStock: Stock): void {
if (this.selectedStockSource) {
return this.selectedStockSource.next(context);
}
this.selectedStockSource = new BehaviorSubject<Stock>(context);
}
public subscribeForStockContextUpdate(): void {
const myWindow = this.ioConnectStore.getIOConnect().windows.my();
myWindow?.onContextUpdated((context: Stock) => {
this._zone.run(() => this.setSelectedStock(context));
});
}
}You must also update the Interop stream handler passed to the onData() method inside subscribeToLivePrices() to use selectedStockSource() instead of the stock object that was previously passed as an argument:
export class IOConnectService {
// Remove the `stock` argument.
public async subscribeToLivePrices(): Promise<IOConnectBrowser.Interop.Subscription> {
subscription.onData((streamData) => {
// Update the handler passed to `onData()` to use `selectedStockSource()`.
const currentStock = this.selectedStockSource.value;
}
}
}Go to the stock-details.ts file of the Stock Details app and invoke subscribeForStockContextUpdate() in ngOnInit(). Use onStockSelected() to update the current stock:
export class StockDetails {
public async ngOnInit(): Promise<void> {
this.stock = this.dataService.selectedStock;
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus() === "available") {
// Remove the argument passed to `subscribeToLivePrices()`.
this.ioConnectSubscription = await this.ioConnectService.subscribeToLivePrices();
// Subscribe for stock context updates.
this.ioConnectService.subscribeForStockContextUpdate();
}
// Set the current stock.
this.ioConnectService.onStockSelected().subscribe((stock) => {
this.stock = stock;
});
}
}Now, when you click on a stock in the Stocks app, the Stock Details app will open below it in the Workspace showing information about the selected stocks. When the user clicks on another stock from Stocks app, the Stock Details will show it.
10. Intents
A new requirement coming from the users is to implement a functionality that exports the portfolio of the selected client. Using the Intents API, you will instrument the Stocks app to raise an Intent for exporting the portfolio, and another app will perform the actual action - the Portfolio Downloader. The benefit of this is that at a later stage of the project, the app for exporting the portfolio can be replaced, or another app for handling the exported portfolio in a different way can also register the same Intent. In any of these cases, code changes in the Stocks app won't be necessary.
ℹ️ See also the Capabilities > Data Sharing > Intents documentation.
The Portfolio Downloader app that will be used in this chapter has already been set up in the 1. Initial Setup and the 2.2. Browser Clients sections of the tutorial and is hosted at http://localhost:4102.
10.1 Registering an Intent
In order for the Portfolio Downloader app to be targeted as an Intent handler, it must be registered as such. Apps can be registered as Intent handlers either by declaring the Intents they can handle in their app definition using the "intents" top-level key and supplying a handler function via the register() method, or dynamically using only the register() method. Using the app definition to register an Intent allows the app to be targeted as an Intent handler even if it isn't currently running. If the app is registered as an Intent handler dynamically, it can act as an Intent handler only during its life span.
The Portfolio Downloader app is already registered as an Intent handler in the applicationsAngular.json file located in the /rest-server/data directory. The only required property is "name", which holds the name of the Intent, but you can optionally specify a display name (e.g., "Download Portfolio", which can later be used in a dynamically generated UI) and a context (predefined data structure, e.g. "ClientPortfolio") with which the app can work:
// In `applicationsAngular.json`.
{
"name": "Portfolio Downloader",
"type": "window",
"details": {
"url": "http://localhost:4102/"
},
// Configuration for handling Intents.
"intents": [
{
"name": "ExportPortfolio",
"displayName": "Download Portfolio",
"contexts": [
"ClientPortfolio"
]
}
]
}Go to the io-connect.service.ts file of the Portfolio Downloader app. In the setupIntentListener() function, pass the name of the Intent and the already implemented intentHandler() function to the register() method, so that it will be called whenever the Portfolio Downloader app is targeted as an Intent handler by the user:
export class IOConnectService {
public async setupIntentListener(): Promise<void> {
const intentName = "ExportPortfolio";
const handler = (context: any) => {
if (context?.type !== "ClientPortfolio") {
return;
}
const client = context.data as Client;
this.selectedClientSource.next(client);
this.startPortfolioDownload(client.name, client.portfolio);
};
// Register the app as an Intent handler.
await this.ioConnectStore.getIOConnect().intents.register(intentName, handler);
}
}Go to the app.ts file of the Portfolio Downloader app, invoke the setupIntentListener() method in onNgInit(), and subscribe for selectedClient():
export class App {
public async ngOnInit(): Promise<void> {
this.ioConnectStatus.set(this.ioConnectService.connectionStatus);
if (this.ioConnectStatus() === "available") {
await this.ioConnectService.setupIntentListener();
}
this.ioConnectService.selectedClient.subscribe(client => {
this.clientName = client?.name;
});
}
}10.2 Raising an Intent
The Stocks app must raise an Intent request when the user clicks a button for exporting the portfolio of the selected client.
Go to the stocks.html file of the Stocks app and uncomment the "Export Portfolio" button in the header.
Go to the io-connect.service.ts file of the Stocks app and define a method for raising an Intent. Perform a check whether an Intent with the name "ExportPortfolio" exists. If so, create an IntentRequest object holding the name of the Intent and specifying targeting behavior and context for it. Use the raise() method to raise an Intent and pass the Intent request object to it:
export class IOConnectService {
public async raiseExportPortfolioIntentRequest(): Promise<void> {
const intentName = "ExportPortfolio";
const intents = await this.ioConnectStore.getIOConnect().intents.find(intentName);
// Check whether an Intent handler that has declared the `"ExportPortfolio"` Intent is available.
if (!intents.length) {
return;
}
// Retrieve the current client from the Workspace context.
const client = await this.getMyWorkspaceContext();
const intentRequest: IOConnectBrowser.Intents.IntentRequest = {
intent: intentName,
context: {
type: "ClientPortfolio",
data: client
}
};
await this.ioConnectStore.getIOConnect().intents.raise(intentRequest).catch(console.error);
}
// Define a method for retrieving the context of the current Workspace.
private async getMyWorkspaceContext(): Promise<Client> {
const myWorkspace = await this.ioConnectStore.getIOConnect().workspaces?.getMyWorkspace();
return myWorkspace?.getContext() as Promise<Client>;
}
}Next, go to the stocks.ts file of the Stocks app and define a method for handling "Export Portfolio" button clicks:
export class Stocks {
public handleExportPortfolioClick(): void {
this.ioConnectService.raiseExportPortfolioIntentRequest().catch(console.error);
}
}Go to the stocks.html file of the Stocks app and bind the method to the click event of the "Export Portfolio" button:
<button type="button" class="io-btn io-btn-primary" (click)="handleExportPortfolioClick()">Export Portfolio</button>Now, clicking on the "Export Portfolio" button will start the Portfolio Downloader app, which will start downloading the portfolio of the currently selected client in JSON format.
11. Notifications
A new requirement from the users is to display a notification whenever a new Workspace has been opened. The notification must contain information for which client is the opened Workspace. Clicking on the notification must focus the Workspaces App and the Workspace for the respective client. You will use the Notifications API to raise a notification when the user clicks on a client to open a Workspace. To the notification onclick property, you will assign a handler for focusing the Workspaces App and the Workspace for the respective client. The handler will be invoked when the user clicks on the notification.
⚠️ Note that you must allow the Main app to send notifications from the browser and also allow receiving notifications from your OS settings, otherwise you won't be able to see the raised notifications.
⚠️ Note that the notifications that will be raised won't contain action buttons. Notifications with action buttons require configuring a service worker, which is beyond the scope of this tutorial.
ℹ️ See also the Capabilities > Notifications documentation.
11.1 Raising a Notification
Go to the io-connect.service.ts file of the Clients app and define a method for raising notifications. Define an object holding a title and body for the notification. Use the raise() method to raise a notification and pass the object with options to it:
export class IOConnectService {
private async raiseNotificationOnWorkspaceOpen(clientName: string, workspace: IOConnectWorkspaces.Workspace): Promise<void> {
const options = {
title: "New Workspace",
body: `A new Workspace for ${clientName} was opened!`,
};
const notification = await this.ioConnectStore.getIOConnect().notifications.raise(options);
}
}Next, go to the restoreWorkspace() method, modify the existing code to call this method and pass the client name and the previously obtained Workspace object:
// In `restoreWorkspace()`.
try {
const workspace = await this.ioConnectStore.getIOConnect().workspaces?.restoreWorkspace("Client Space", { context: client });
await this.raiseNotificationOnWorkspaceOpen(client.name, workspace);
} catch (error: any) {
console.error(JSON.stringify(error));
}Now, a notification will be raised whenever a new Workspace has been opened.
11.2 Notification Handler
Go to the raiseNotificationOnWorkspaceOpen() method and use the onclick property of the previously obtained Notification object to assign a handler for focusing the Workspaces App and the Workspace for the respective client:
// In `raiseNotificationOnWorkspaceOpen()`.
notification.onclick = () => {
// This will focus the Workspaces App.
workspace.frame.focus().catch(console.error);
// This will focus the Workspace for the respective client.
workspace.focus().catch(console.error);
};Now, when the user clicks on a notification, the Workspaces App and the Workspace for the respective client will be focused.
Congratulations
You have successfully completed the io.Connect Browser Angular tutorial! See also the JavaScript and React tutorials for io.Connect Browser.