Skip to content

Dev UI v2

Clement Escoffier edited this page Feb 7, 2023 · 20 revisions

Quarkus 3 provides the base for a new Dev UI. This new Dev UI uses Web Components (Lit and Vaadin components) and Json RPC over Web Sockets, to provide a more interactive and modern experience.

This page contains a list of how to to help extend this new dev UI.

Layout

devui1

  • Menu: Main sections of Dev UI.
  • Page: Where sections and extensions can display their content.
  • Card: Extensions can add links to their cards that will navigate to their page (above) displaying their content.
  • Footer: Bottom drawer that allows log related content.

devui2

When a extension linked is clicked, the extension has got full control of the page, and if the extension has more than one link on their card, those links will form the sub-menu in the header.

Extensions can take part in the Dev UI in 3 ways:

  • Link(s) on their card.
  • Menu item(s) in the menu.
  • Tab(s) in the footer.

In most cases only links on cards will be used.

Cards

Cards are automatically added for all extensions in the running project. By default information on the extension will be displayed (from src/main/resources/META-INF/quarkus-extension.yaml)

devui3

You can add links to that card by creating a processor with a build step producing an io.quarkus.devui.spi.page.PageBuildItem:

public class SmallRyeHealthDevUiProcessor {

    @BuildStep(onlyIf = IsDevelopment.class) // <1> Only for dev.
    CardPageBuildItem create() {
        CardPageBuildItem cardPageBuildItem = new CardPageBuildItem("Smallrye Health"); // <2> This name must be the extension name

        // Add simple links
        cardPageBuildItem.addPage(Page.externalPageBuilder("Health") // <3> The link label
                .icon("font-awesome-solid:stethoscope") // <4> The link icon
                .url("/q/health")  // <5> The link target
                .isJsonContent());  // <6> The type of content

        cardPageBuildItem.addPage(Page.externalPageBuilder("Health UI")
                .icon("font-awesome-solid:stethoscope")
                .url("/q/health-ui")
                .isHtmlContent());

        return pageBuildItem;
    }

}

There are multiple Page Builder that helps with the creating of these pages:

  • externalPageBuilder - Build a link to a page that is outside of the scope of Dev UI. By default it will be embedded in the page view of the extension. To not embed it (but rather navigate out to the external link) use .doNotEmbed() on the builder.
  • webComponentPageBuilder - Build a link to a page that is rendered with a Web Component.
  • TODO: add other builders

Use web components

One key difference with the previous dev UI is the usage of Web Components. So, a card link can navigate to a web component full page.

The web component interacts with the backend using JSON RPC (over a web socket). It allows bi-directional interactions, including streams.

To create a card linking a web component, create a processor with the following build steps:

public class CacheDevUiConsoleProcessor {

    @BuildStep(onlyIf = IsDevelopment.class)
    CardPageBuildItem create(CurateOutcomeBuildItem bi) {
        CardPageBuildItem cardPageBuildItem = new CardPageBuildItem("Cache"); // Must be the extension name
        cardPageBuildItem.addPage(Page.webComponentPageBuilder()
                .title("Caches")
                .componentLink("cache-component.js")
                .icon("font-awesome-solid:database"));

        return pageBuildItem;
    }

    @BuildStep(onlyIf = IsDevelopment.class)
    JsonRPCProvidersBuildItem createJsonRPCServiceForCache() {
        return new JsonRPCProvidersBuildItem("Caches", CacheJsonRPCService.class);
    }
}

Let's focus on the create method. Unlike the one from SmallRyeHealthDevUiProcessor, this one creates a WebComponentPage. It defines the title of that page, the link to the web component JS file, and an optional icon.

The createJsonRPCServiceForCache defines a JsonRPCProvidersBuildItem, which will be the backend component interacting with the web component.

Build time data

You can make data created at build time, available in your web component by using the addBuildTimeData method of the CardPageBuildItem:

CardPageBuildItem cardPageBuildItem = new CardPageBuildItem("Cache");
cardPageBuildItem.addBuildTimeData("initialCaches", initialCache);

initialCaches can be any JsonObject or JsonArray or any POJO.

That will be available in your web component to import:

import { initialCaches } from 'cache-data';

Note the const being imported match the key you used in addBuildTimeData and the from is your extension name + -data;

The value is then usable in your component as a normal Json object.

The JSON RPC backend

The Dev UI uses JSON RPC over a web socket. However, to simplify the code, the extension produces a JsonRPCProvidersBuildItem. This build item sets the (JavaScript) name of the API (Caches in our example) and the class handling the interaction (CacheJsonRPCService). This class must be in the runtime module of the extension and is a CDI bean:

@ApplicationScoped
public class CacheJsonRPCService {

    @Inject
    CacheManager manager;

    // Called using jsonRpc.Caches.getAll()
    public JsonArray getAll() {
        Collection<String> names = manager.getCacheNames();
        List<CaffeineCache> allCaches = new ArrayList<>(names.size());
        for (String name : names) {
            Optional<Cache> cache = manager.getCache(name);
            if (cache.isPresent() && cache.get() instanceof CaffeineCache) {
                allCaches.add((CaffeineCache) cache.get());
            }
        }
        allCaches.sort(Comparator.comparing(CaffeineCache::getName));

        var array = new JsonArray();
        for (CaffeineCache cc : allCaches) {
            array.add(getJsonRepresentationForCache(cc));
        }
        return array;
    }

    // Called using jsonRpc.Caches.clear(name: name)
    public JsonObject clear(String name) {
        Optional<Cache> cache = manager.getCache(name);
        if (cache.isPresent()) {
            cache.get().invalidateAll().subscribe().asCompletionStage();
            return getJsonRepresentationForCache(cache.get());
        } else {
            return new JsonObject().put("name", name).put("size", -1);
        }
    }

    // Called using jsonRpc.Caches.clear(name: name)
    public JsonObject refresh(String name) {
        System.out.println("refresh called for " + name);
        Optional<Cache> cache = manager.getCache(name);
        if (cache.isPresent()) {
            return getJsonRepresentationForCache(cache.get());
        } else {
            return new JsonObject().put("name", name).put("size", -1);
        }
    }

    private static JsonObject getJsonRepresentationForCache(Cache cc) {
        return new JsonObject().put("name", cc.getName())
            .put("size", ((CaffeineCacheImpl) cc).getSize());
    }

}

Each of the public method from the bean is exposed with JSON RPC. It means the web component can invoke them and get the results. In our example, we have three methods:

  • getAll - retrieving all the caches
  • clear - invalidating all the items from a cache
  • refresh - gets up-to-date representation of a cache

Note that these methods return JSON object / JSON array. Other types are supported too, but JSON eases the integration with the frontend. When you have Jackson Databind in your dependencies you can also return a POJO. By default Jackson Databind is not included, so only use this when your extension is adding that to the dependencies anyway.

The Web component

The web component is implemented in JavaScript. The file is located in the deployment module, in the src/main/resources/dev-ui/<extension name>/ directory. In our case, it's the src/main/resources/dev-ui/cache/cache-component.js file.

The content is a web component using Lit and Vaadin. A Web Component is JavaScript class extending LitElement.

The Web Component JavaScript file:

  • imports the web component it relies on.
  • is declared as a class with: export class MyComponent extend ListElement.
  • can contain its own style using static styles= css\...``.
  • can access the JSON RPC bridge using jsonRpc = new JsonRpc("the name set in the JsonRPCProvidersBuildItem");.
  • defines its state properties in static properties = {}.
  • can override component interface methods like connectedCallback, disconnectedCallback, render...
  • define how it's presented on the screen using the render method.

The following snippet is an example of a Web Component:

import { LitElement, html, css} from 'lit';
import { JsonRpc } from 'jsonrpc';
import '@vaadin/icon';
import '@vaadin/button';
import { until } from 'lit/directives/until.js';
import '@vaadin/grid';
import { columnBodyRenderer } from '@vaadin/grid/lit.js';
import '@vaadin/grid/vaadin-grid-sort-column.js';

export class CacheComponent extends LitElement {

    jsonRpc = new JsonRpc("Caches");

    // Component style
    static styles = css`
        .button {
            background-color: transparent;
            cursor: pointer;
        }
        .clearIcon {
            color: orange;
        }
        `;

    // Component properties
    static properties = {
        "_caches": {state: true}
    }

    // Components callbacks

    /**
     * Called when displayed
     */
    connectedCallback() {
        super.connectedCallback();
        this.jsonRpc.getAll().then(jsonRpcResponse => {
            this._caches = new Map();
            jsonRpcResponse.result.forEach(c => {
                this._caches.set(c.name, c);
            });
        });
    }

    /**
     * Called when it needs to render the components
     * @returns {*}
     */
    render() {
        return html`${until(this._renderCacheTable(), html`<span>Loading caches...</span>`)}`;
    }

    // View / Templates

    _renderCacheTable() {
        if (this._caches) {
            let caches = [...this._caches.values()];
            return html`
                <vaadin-grid .items="${caches}" class="datatable" theme="no-border">
                    <vaadin-grid-column auto-width
                                        header="Name"
                                        ${columnBodyRenderer(this._nameRenderer, [])}>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Size"
                                        path="size">
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header=""
                                        ${columnBodyRenderer(this._actionRenderer, [])}
                                        resizable>
                    </vaadin-grid-column>
                </vaadin-grid>`;
        }
    }

    _actionRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._clear(cache.name)} class="button">
                <vaadin-icon class="clearIcon" icon="font-awesome-solid:broom"></vaadin-icon> Clear
            </vaadin-button>`;
    }

    _nameRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._refresh(cache.name)} class="button">
                <vaadin-icon icon="font-awesome-solid:rotate"></vaadin-icon>
            </vaadin-button>
            ${cache.name}`;
    }

    _clear(name) {
        this.jsonRpc.clear({name: name}).then(jsonRpcResponse => {
            this._updateCache(jsonRpcResponse.result)
        });
    }

    _refresh(name) {
        this.jsonRpc.refresh({name: name}).then(jsonRpcResponse => {
            this._updateCache(jsonRpcResponse.result)
        });
    }

    _updateCache(cache){
        if (this._caches.has(cache.name)  && cache.size !== -1) {
            this._caches.set(cache.name, cache);
            this.requestUpdate(); // Required because we use a Map, so we do not re-assign the state variable.
        }
    }

}
customElements.define('cache-component', CacheComponent);

The last line is essential, as it registers the web component.

Let's look into that code. After the list of imports, it does the following:

export class CacheComponent extends LitElement {

This is the beginning of the web component. Because our web component interacts with the backend, we instantiate the JSON RPC bridge:

jsonRpc = new JsonRpc("Caches");

This object will let us invoke the getAll, clear and refresh methods. Caches is the name passed in the JsonRPCProvidersBuildItem.

A web component can define its style:

// Component style
    static styles = css`
        .button {
            background-color: transparent;
            cursor: pointer;
        }
        .clearIcon {
            color: orange;
        }
        `;

This style is not leaked outside of the web component.

A web component has properties, including state variables:

// Component properties
    static properties = {
        "_caches": {state: true}
    }

The state:true is important as it triggers the rendering of the component if this variable is modified.

Accessing the properties is done using this._caches. Note that using _ is a convention to denote internal attributes and methods (like private in Java).

A web component can implement lifecycle hooks such as:

  • connectedCallback - called when the web component is inserted in the DOM
  • disconnectedCallback - called when the web component is removed from the DOM
  • render - called when the UI needs to be updated.

In our example, the connectedCallback retrieves the caches from the backend:

connectedCallback() {
        super.connectedCallback();
        this.jsonRpc.getAll().then(jsonRpcResponse => {
            this._caches = new Map();
            jsonRpcResponse.result.forEach(c => {
                this._caches.set(c.name, c);
            });
        });
    }

this.jsonRpc.getAll() sends a JSON RPC message to the backend (to invoke the getAll() method), and when the response is received invokes the then callback. We initiate the _cache state variable in that callback.

The render method is the central piece of our web component. It defines how the component is rendered on screen:

/**
     * Called when it needs to render the components
     * @returns {*}
     */
    render() {
        return html`${until(this._renderCacheTable(), html`<span>Loading caches...</span>`)}`;
    }

    // View / Templates

    _renderCacheTable() {
        if (this._caches) {
            let caches = [...this._caches.values()];
            return html`
                <vaadin-grid .items="${caches}" class="datatable" theme="no-border">
                    <vaadin-grid-column auto-width
                                        header="Name"
                                        ${columnBodyRenderer(this._nameRenderer, [])}>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Size"
                                        path="size">
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header=""
                                        ${columnBodyRenderer(this._actionRenderer, [])}
                                        resizable>
                    </vaadin-grid-column>
                </vaadin-grid>`;
        }
    }

     _actionRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._clear(cache.name)} class="button">
                <vaadin-icon class="clearIcon" icon="font-awesome-solid:broom"></vaadin-icon> Clear
            </vaadin-button>`;
    }

    _nameRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._refresh(cache.name)} class="button">
                <vaadin-icon icon="font-awesome-solid:rotate"></vaadin-icon>
            </vaadin-button>
            ${cache.name}`;
    }

In our example, we use Vaadin components to display a table with three columns: the cache name, size, and a "clear" action. The name is rendered using a custom renderer to also contain an "update" action.

The rest of the methods are implementing various actions.

Inject HTML content

When your backend returns HTML content, you need to use the unsafeHTML function:

import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';

// ...

return html`<vaadin-icon icon="font-awesome-solid:syringe" title="channel"></vaadin-icon> ${unsafeHTML(component.description)}`

Display an icon

import '@vaadin/icon';

//...

html`<vaadin-icon icon="font-awesome-solid:syringe"></vaadin-icon>

Find the name of the icon at https://fontawesome.com/icons.

Theme

Dev UI comes build in with a dark and light mode. The default will be determined by the user's OS setting (or light if not set).

Dark Light
devui-dark devui-light

When using the underlying vaadin components, the theme is supported by default, and as a extension developer you do not need to do anything more. When using plain html or other components, you can use the CSS Variables as defined here:

https://vaadin.com/docs/latest/styling/lumo/design-tokens/color

Example (in the web component):

static styles = css`
        span {
            cursor: pointer;
            font-size: small;
            color: var(--lumo-contrast-50pct);
        }
        span:hover {
            color: var(--lumo-primary-color-50pct);
        }`;

Menu

You can also add a link directly on the Menu, that points to your page:

WebComponentPageBuilder menuItemPageBuilder = Page.webComponentPageBuilder()
                .icon("font-awesome-regular:face-grin-tongue-wink")
                .componentLink("qwc-jokes-menu.js");

MenuPageBuildItem menuPageBuildItem = new MenuPageBuildItem("Jokes", menuItemPageBuilder);

devui4

Footer

TODO: Show how to participate in the footer

Migrating from a runtime template to a Web Component

Many extension provides their view into the previous dev UI using a runtime template. This template was rendered at runtime.

That feature does not yet exist with the new Dev UI, but migrating that view to a Web Component is quite simple. This section is a step-by-step explanation of that kind of migration. To illustrate the process, we will take the reactive messaging extension.

The front-end part is just a table presenting the channels and, for each channel, the publishers and subscribers.

The processor

The existing processor (io.quarkus.smallrye.reactivemessaging.deployment.devconsole.ReactiveMessagingDevConsoleProcessor) contains the following build step:

    @BuildStep(onlyIf = IsDevelopment.class)
    public DevConsoleRuntimeTemplateInfoBuildItem collectInfos(CurateOutcomeBuildItem curateOutcomeBuildItem) {
        return new DevConsoleRuntimeTemplateInfoBuildItem("reactiveMessagingInfos",
                new DevReactiveMessagingInfosSupplier(), this.getClass(), curateOutcomeBuildItem);
    }

In a new processor (like io.quarkus.smallrye.reactivemessaging.deployment.devconsole.ReactiveMessagingDevUiProcessor), add:

public class ReactiveMessagingDevUiProcessor {

    @BuildStep(onlyIf = IsDevelopment.class)
    CardPageBuildItem create() {
        CardPageBuildItem card = new CardPageBuildItem("SmallRye Reactive Messaging");
        card.addPage(Page.webComponentPageBuilder()
                .title("Channels")
                .componentLink("smallrye-reactive-messaging-component.js")
                .icon("font-awesome-solid:database"));

        return card;
    }

    @BuildStep(onlyIf = IsDevelopment.class)
    JsonRPCProvidersBuildItem createJsonRPCService() {
        return new JsonRPCProvidersBuildItem("ReactiveMessaging", ReactiveMessagingJsonRpcService.class);
    }
}

The first method (create) registers a card to the extension page. Note that the name (SmallRye Reactive Messaging) must match the extension name. In the card, let's add a link targeting our next-to-be web component:

card.addPage(Page.webComponentPageBuilder()
                .title("Channels")
                .componentLink("smallrye-reactive-messaging-component.js")
                .icon("font-awesome-solid:database"));

There is a second method in the processor (createJsonRPCService). It registers a bean that will handle the frontend <-> backend communication. The new Dev UI uses JSON RPC over a web socket. So, the method produces a JsonRPCProvidersBuildItem with a name (the JavaScript attribute to retrieve the object to interact with the backend in our web component) and the backend class.

The JSON RPC Service

Let's now look at the io.quarkus.smallrye.reactivemessaging.runtime.devconsole.ReactiveMessagingJsonRpcService class. First, it's a runtime class. The public methods from that class are exposed using the JSON RPC bridge. In our case, we have a single method:

@ApplicationScoped
public class ReactiveMessagingJsonRpcService {

    public DevReactiveMessagingInfos getInfo() {
        return new DevReactiveMessagingInfos();
    }
}

This replaces the data structure we passed to the template (which was also a runtime structure): DevReactiveMessagingInfos. So, we just reuse the same structure, except that this time, it's retrieved using JSON RPC.

Note that the return type can be a JSON Object, JSON Array, or a bean. The latter requires your extension to depend on Jackson (databind).

The web component

That's the main part of our migration. We need to migrate from a template to a web component.

That web component is located into the deployment module in the src/main/resources/dev-ui/smallrye-reactive-messaging directory. The name of the directory must be the name of the extension. As set in the processor code, we expect the web component JavaScript to be named: smallrye-reactive-messaging-component.js. So, the file is src/main/resources/dev-ui/smallrye-reactive-messaging/smallrye-reactive-messaging-component.js.

A web component is structured as follows:

  1. some imports
  2. The class
  3. In the class structure, the jsonRPC bridge, if needed (in our case, we need it)
  4. In the class structure, the component style (CSS), if needed (we don't)
  5. In the class structure, the property of the web component
  6. In the class structure, the connectedCallback and render methods. The first one is called when the web component is added to the DOM. The second one is called to gets the presentation of the wb component.
  7. In the class structure, all the other methods we may need
  8. Outside the class structure, the registration of our custom web component.

So:

// <1>
import { LitElement, html, css} from 'lit';
import { JsonRpc } from 'jsonrpc';
import '@vaadin/icon';
import '@vaadin/button';
import { until } from 'lit/directives/until.js';
import '@vaadin/grid';
import { columnBodyRenderer } from '@vaadin/grid/lit.js';
import '@vaadin/grid/vaadin-grid-sort-column.js';
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';

// <2>
export class SmallryeReactiveMessagingComponent extends LitElement {

    // <3>
    jsonRpc = new JsonRpc("ReactiveMessaging");

    // <5>
    static properties = {
        "_channels": {state: true, type: Array}
    }


    // <6>
    connectedCallback() {
        super.connectedCallback();
        console.log("connected callback")
        this.jsonRpc.getInfo().then(jsonRpcResponse => {
            console.log("info are:", jsonRpcResponse.result);
            this._channels = [];
            jsonRpcResponse.result.channels.forEach(c => {
                console.log("Channel", c);
                this._channels.push(c);
            });
        });
    }

   
    render() {
        return html`${until(this._renderChannelTable(), html`<span>Loading channels...</span>`)}`;
    }


    _renderChannelTable() {
        if (this._channels) {
            return html`
                <vaadin-grid .items="${this._channels}" class="datatable" theme="no-border">
                    <vaadin-grid-column auto-width
                                        header="Channel"
                                        ${columnBodyRenderer(this._channelNameRenderer, [])}>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Publisher"
                                        ${columnBodyRenderer(this._channelPublisherRenderer, [])}>>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Subscriber(s)"
                                        ${columnBodyRenderer(this._channelSubscriberRenderer, [])}
                                        resizable>
                    </vaadin-grid-column>
                </vaadin-grid>`;
        }
    }

    _channelNameRenderer(channel) {
        return html`<strong>${ channel.name }</strong>`
    }
        
    _channelSubscriberRenderer(channel) {
        const consumers = channel.consumers;
        if (consumers) {
            if (consumers.length === 1) {
                return this._renderComponent(consumers[0]);
            } else if (consumers.length > 1) {
                return html`
                  <ul>
                    ${consumers.map(item => html`<li>${this._renderComponent(item)}</li>`)}
                  </ul>
                `;
            } else {
                return html`<em>No subscribers</em>`
            }
        }
    }

    _channelPublisherRenderer(channel) {
        const publisher = channel.publisher;
        if (publisher) {
            return this._renderComponent(publisher);
        }
    }

    _renderComponent(component) {
       // ...
        }
    }
}
// <8>
customElements.define('smallrye-reactive-messaging-component', SmallryeReactiveMessagingComponent);

Let's start with <1>. Like in Java, we need some imports. Each function or web component we use needs to be imported.

<2> is the component class declaration:

export class SmallryeReactiveMessagingComponent extends LitElement {

You give a class name, and it must extend LitElement.

<3> is only required if you need frontend <-> backend communication. Here we do need it to retrieve the DevReactiveMessagingInfos object. So, we instantiate a JsonRPC. The name must match the one you set in the JSON-RPC Build Item (in the processor).

<5> declares the fields of the component. Here we have a single one: _channels. It's the set of channels as an array.

<6> contains both connectedCallback and the render method. connectedCallback is a bit like the @PostContruct from CDI. In our case, we want to retrieve the DevReactiveMessagingInfos object:

connectedCallback() {
    super.connectedCallback();
    this.jsonRpc.getInfo().then(jsonRpcResponse => {
        this._channels = [];
        jsonRpcResponse.result.channels.forEach(c => {
            this._channels.push(c);
        });
    });
}

Using the JSON-RPC bridge, we invoke the getInfo() method to retrieve the DevReactiveMessagingInfos. The getInfo() method returns a promise. So, we can use then to be notified when the result is received. Note that the content is available in the jsonRpcResponse.result field. Then, we just passed the received channels into the component property _channels.

The render function is where we must deal with the presentation layer. Web component uses some kind of template. We do not generate HTML content (while we could), but we can use other web components.

But first, let's look at the render method:

render() {
        return html`${until(this._renderChannelTable(), html`<span>Loading channels...</span>`)}`;
}

The until function allows us to display a waiting message while the _renderChannelTable return something. So, if the render method is invoked before the JSON-RPC getInfos() promise gets completed, we do not display a blank page.

This is why the _renderChannelTable function starts with:

if (this._channels) {
  // ...
}

So, if the component is not yet initialized, it returns nothing, and until waits (displaying the passed message).

Note the syntax: html\Loading channels...`. It allows the creation of HTML elements and snippets. In such a template, you can use the ${ ... }` syntax to execute some logic or reference variables (including functions and component fields).

Let's now look at the _renderChannelTable. This method reproduces the table from the template used with the previous Dev UI, i.e., 3 columns (name, publishers, subscribers).

_renderChannelTable() {
        if (this._channels) {
            return html`
                <vaadin-grid .items="${this._channels}" class="datatable" theme="no-border">
                    <vaadin-grid-column auto-width
                                        header="Channel"
                                        ${columnBodyRenderer(this._channelNameRenderer, [])}>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Publisher"
                                        ${columnBodyRenderer(this._channelPublisherRenderer, [])}>>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Subscriber(s)"
                                        ${columnBodyRenderer(this._channelSubscriberRenderer, [])}
                                        resizable>
                    </vaadin-grid-column>
                </vaadin-grid>`;
        }
    }

Instead of a regular HTML table (which would have worked), we use a Vaadin Grid. So, it has a nice default style and can store the columns. The .items="${this._channels}" indicates what the table represents. Here, we bind the table to our _channels field. So, if this field is updated, the table is re-drawn. You do not need to handle the iteration over the list; it's done automatically. So each line represents one channel.

In the <vaadin-grid> element, we declare the three columns. Each use a specific ${columnBodyRenderer(..., [])}. While not required, it allows customizing how the cell will be rendered.

For example, the channel name is rendered as follows:

_channelNameRenderer(channel) {
        return html`<strong>${ channel.name }</strong>`
}

The _channelSubscriberRenderer is a bit more interesting, as it uses map:

_channelSubscriberRenderer(channel) {
        const consumers = channel.consumers;
        if (consumers) {
            if (consumers.length === 1) {
                return this._renderComponent(consumers[0]);
            } else if (consumers.length > 1) {
                return html`
                  <ul>
                    ${consumers.map(item => html`<li>${this._renderComponent(item)}</li>`)}
                  </ul>
                `;
            } else {
                return html`<em>No subscribers</em>`
            }
        }
    }

The interesting part is ${consumers.map(item => html

  • ${this._renderComponent(item)}
  • )}. It generates a <li> for each consumer.

    In the previous Dev UI, each publisher, subscriber, and connector... had a custom icon. This variability is implemented in the _renderComponent method. For each type, it uses an icon and prints the description. Because that description is an HTML snippet, you need to use the unsafeHTML. Otherwise, the HTML element would be escaped:

    return html`<vaadin-icon icon="font-awesome-solid:right-to-bracket" title="subscriber"></vaadin-icon> 
        ${unsafeHTML(component.description)}`

    Finally, let's look at <8>:

    customElements.define('smallrye-reactive-messaging-component', SmallryeReactiveMessagingComponent);

    It registers our web component. SmallryeReactiveMessagingComponent is the name of the class.

    Voilà, we went from a template to a web component. Remember to do that you need the following:

    • to use JSON-RPC to retrieve the data
    • use a Web Component to display that data as the template did

    Current Version

    Migration Guide 3.16

    Next Version

    Migration Guide 3.17

    Clone this wiki locally