Font Size:
Ask Joget AI

How To Develop a Console Page Plugin

Introduction

This guide serves as the definitive reference for developers building custom admin console pages in the Joget platform using the ConsolePagePlugin interface. It covers the core architectural requirements for creating, packaging, and deploying a Joget plugin as an OSGi bundle.

Whether you are building simple administrative utilities, complex dashboards, or server-side rendered tools, this guide provides the essential technical implementation details. You will learn how to implement the ConsolePagePlugin lifecycle, manage custom URL routing using @ConsolePagePlugin.Path, handle static assets, and ensure secure, authorized access within the Joget admin console. By following these standards, you will create robust plugins that integrate seamlessly with the Joget platform’s core monitoring and configuration sections.

Conceptual Overview

High-level request flow

Browser → GET /web/console/plugin/<PluginName>
        ↓
    render() → returns HTML shell (CSS link + FTL + app.js)
        ↓
    Browser executes app.js → createApp().mount('#io-app')

Prerequisites

Tool

Version

Java JDK

17

Maven

3.8+

Joget DX

8.x or 9.x (this guide targets 9.x)

IDE

IntelliJ / VS Code with Java + XML support

Browser

Any modern (Chrome / Firefox / Edge) for testing

Joget Maven dependencies must be reachable. Either: - Install Joget locally (mvn install the wflow-core module from the Joget source)

Project Layout

my-plugin/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/
    │   │   └── com/example/myplugin/
    │   │       ├── Activator.java                              ← OSGi lifecycle
    │   │       └── plugin/
    │   │           └── MyDashboardPlugin.java                   ← Joget plugin class
    │   └── resources/
    │       ├── messages/
    │       │   └── myPlugin.properties                          ← i18n strings
    │       ├── templates/
    │       │   └── MyDashboard.ftl                              ← HTML shell (FTL)
    │       └── resources/                                       ← AUTO-SERVED static assets
    │           ├── css/
    │           │   └── MyDashboard.css                          ← Styles
    │           └── images/
    │               └── logo.png                                 ← Any image, font, etc.
    └── test/
        └── java/com/example/myplugin/                           ← JUnit tests

The Key foldersrc/main/resources/resources/. Anything you place under this folder is auto-served by Joget at:

${request.contextPath}/plugin/<fully.qualified.plugin.class>/<path-inside-resources-resources>

For example, src/main/resources/resources/js/MyDashboard.js is reachable at:

${request.contextPath}/plugin/com.example.myplugin.plugin.MyDashboardPlugin/js/MyDashboard.js

This means you do not need to write @Path endpoints for CSS, JS, images, fonts, or any other static asset;  just drop files into resources/resources/ and reference them by URL.

Naming convention:

  • Java package mirrors com.example.myplugin.plugin.MyDashboardPlugin
  • FTL templates live under templates/ (loaded via pluginManager.getPluginFreeMarkerTemplate(...))
  • Static assets live under resources/ (auto-served, no Java code needed) 
  • File names match the plugin class name (MyDashboard.ftl, MyDashboard.js, MyDashboard.css)

Maven & OSGi Build Configuration (pom.xml)

Three things matter in the POM:

  1. <packaging>bundle</packaging> produces an OSGi bundle JAR via maven-bundle-plugin.
  2. <Bundle-Activator> points to your Activator class.
  3. <Import-Package> explicitly imports Joget framework packages; everything else is embedded.

Minimal working POM:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>my-vue-plugin</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>bundle</packaging>
    <name>my-vue-plugin</name>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.apache.felix</groupId>
                <artifactId>maven-bundle-plugin</artifactId>
                <version>5.1.9</version>
                <extensions>true</extensions>
                <configuration>
                    <instructions>
                        <Export-Package></Export-Package>
                        <Private-Package>{local-packages}</Private-Package>
                        <Bundle-Activator>com.example.myplugin.Activator</Bundle-Activator>
                        <!-- Only import what you actually use; embed everything else -->
                        <Import-Package>
                            !*,
                            org.joget.commons.util,
                            org.joget.plugin.base,
                            org.joget.apps.app.model,
                            org.joget.apps.app.service,
                            org.joget.workflow.model.service,
                            org.osgi.framework;version="1.3.0"
                        </Import-Package>
                        <Embed-Dependency>*;scope=compile|runtime;inline=false</Embed-Dependency>
                        <Embed-Transitive>true</Embed-Transitive>
                        <Embed-Directory>dependency</Embed-Directory>
                        <Embed-StripGroup>true</Embed-StripGroup>
                        <DynamicImport-Package>*</DynamicImport-Package>

                        <Joget-Name>My Vue Dashboard</Joget-Name>
                        <Joget-Description>Sample Vue.js admin page plugin.</Joget-Description>
                    </instructions>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.joget</groupId>
            <artifactId>wflow-core</artifactId>
            <version>9.0-SNAPSHOT</version>
            <scope>provided</scope>
            <exclusions>
                <exclusion>
                    <groupId>javax.servlet</groupId>
                    <artifactId>javax.servlet-api</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.0.0</version>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

Key bundle-plugin notes

  • !* at the start of Import-Package tells BND: do not auto-import packages, only import what is explicitly listed below it. This avoids Unresolved constraint errors at OSGi load time.
  • provided scope for Joget jars: the platform already provides these at runtime; don’t embed them.
  • Embed-Dependency embeds your runtime jars (e.g., a JSON library) into the dependency/ inside your bundle JAR.
  • DynamicImport-Package: * is a safety net so the bundle can resolve classes loaded reflectively at runtime.

OSGi Activator (Plugin Registration)

Activator.java is the OSGi entry point; it registers your plugin with the Joget OSGi context on bundle start and unregisters on stop.

package com.example.myplugin;

import java.util.ArrayList;
import java.util.Collection;

import com.example.myplugin.plugin.MyDashboardPlugin;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;

public class Activator implements BundleActivator {

    protected Collection<ServiceRegistration> registrationList;

    @Override
    public void start(BundleContext context) {
        registrationList = new ArrayList<>();
        registrationList.add(context.registerService(
                MyDashboardPlugin.class.getName(),
                new MyDashboardPlugin(),
                null));
    }

    @Override
    public void stop(BundleContext context) {
        for (ServiceRegistration registration : registrationList) {
            registration.unregister();
        }
    }
}

If you spawn background threads

If your plugin creates an ExecutorService (e.g., for async data collection), expose it as a static accessor and shut it down here:

@Override
public void stop(BundleContext context) {
    try {
        ExecutorService executor = MyDashboardPlugin.getSharedExecutor();
        executor.shutdown();
        if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
            executor.shutdownNow();
        }
    } catch (Exception e) {
        // log and continue
    }
    for (ServiceRegistration r : registrationList) {
        r.unregister();
    }
}

This prevents thread leaks across hot-reloads.

The ConsolePagePlugin API Complete Reference

This section is the definitive reference for ConsolePagePlugin,  every interface method, every annotation, the Location enum, the routing/dispatch model, and the supported parameter types. Whether or not you use Vue, you’ll need everything in this section.

Subclass ConsolePagePluginAbstract (which implements the ConsolePagePlugin interface) and implement the lifecycle/metadata methods, then add a render() method and one or more @ConsolePagePlugin.Path("…") methods for sub-routes.

Skeleton

package com.example.myplugin.plugin;

import org.joget.apps.app.model.ConsolePagePluginAbstract;
import org.joget.apps.app.service.AppUtil;
import org.joget.plugin.base.ConsolePagePlugin;
import org.joget.plugin.base.PluginManager;
import org.joget.workflow.model.service.WorkflowUserManager;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.util.HashMap;
import java.util.Map;

public class MyDashboardPlugin extends ConsolePagePluginAbstract {

    static final String PLUGIN_NAME = "My Vue Dashboard";
    private static final String MESSAGE_PATH = "messages/myPlugin";

    @Override public String getName()        { return PLUGIN_NAME; }
    @Override public String getVersion()     { return "1.0-SNAPSHOT"; }
    @Override public String getDescription() { return "Sample Vue.js dashboard plugin."; }
    @Override public String getLabel()       { return "My Vue Dashboard"; }
    @Override public String getPluginIcon()  { return "<i class=\"fas fa-chart-line\"></i>"; }

    @Override
    public int getOrder() { return 100; }

    @Override
    public ConsolePagePlugin.Location getLocation() {
        // Where this page appears in the admin console sidebar.
        return ConsolePagePlugin.Location.MONITOR;
    }

    @Override
    public boolean isAuthorized() {
        WorkflowUserManager wum = (WorkflowUserManager) AppUtil
                .getApplicationContext().getBean("workflowUserManager");
        return wum.isCurrentUserInRole(WorkflowUserManager.ROLE_ADMIN);
    }

    // ... render() and @Path methods below
}

Required interface methods

These are defined on ConsolePagePlugin and must be implemented:

Method

Returns

Purpose

getName()

String

Unique identifier of the page (used in URL and plugin registry). Avoid spaces to keep URLs clean.

getLabel()

String

Human-readable label shown in the admin sidebar menu

getPluginIcon()

String (HTML)

Icon markup is typically a Font Awesome <i> tag

getOrder()

int

Sort order within the menu section. Convention: multiples of 100 (100, 200, 300…) to leave room for plugins to slot between built-ins.

getLocation()

ConsolePagePlugin.Location

Which sidebar section does this page belong to (see Section 6.3)

isAuthorized()

boolean

Per-request access gate returns true if the current user may see this menu item and access the page. Called both at menu render time AND for every request to the plugin’s URLs.

render(req, resp)

String (HTML)

Default page renderer. Returns the HTML that appears when the user navigates to the plugin’s base URL.

Plus inherited from ExtDefaultPlugin (via ConsolePagePluginAbstract):

Method

Returns

Purpose

getVersion()

String

Plugin version, shown in Plugin Manager

getDescription()

String

Description shown in Plugin Manager

The Location enum

ConsolePagePlugin.Location is a nested enum with exactly three values:

Value

Sidebar section

Default icon (HTML)

Location.DIRECTORY

Users / Directory section

<i class="fas fa-users">

Location.MONITOR

Monitor section (dashboards, logs, system health)

<i class="fas fa-tachometer-alt"></i>

Location.SETTINGS

System settings

<i class="fas fa-cogs"></i>

There is no APPLICATIONS value. App-level pages use a different plugin type (UserviewMenuPlugin, not ConsolePagePlugin). If you need a page under Applications, you’re looking at the wrong plugin type.

The enum exposes helper methods:

  • getLabel() returns the i18n-resolved menu section label
  • getIcon() returns the icon HTML markup for the section

The @ConsolePagePlugin.Path annotation

@Path marks a method as an additional URL handler beyond the default render() method. This is how you add REST endpoints, AJAX data APIs, file downloads, exports, etc.

Annotation definition (from wflow-plugin-base/.../ConsolePagePlugin.java):

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Path {
    String[] value() default {};   // one or more AntPathMatcher patterns
}

Key facts:

  • Path patterns use Spring’s AntPathMatcher syntax, not JAX-RS. So:
    • /data matches exactly /data
    • /form/{id} matches /form/123 and binds id=123
    • /api/** matches any sub-path under /api/
    • /users/* matches one segment (e.g., /users/owen but not /users/owen/edit)
  • You can supply multiple patterns by passing an array: @Path({"/data", "/data/{id}"}).
  • The full URL is ${contextPath}/web/console/plugin/<URL-encoded-getName()>/<your-path>.
  • Method return value can be void (when you write directly to HttpServletResponse) or String (returned as HTML response body treated like a render() return value).
  • isAuthorized() is checked before every @Path method call. Joget enforces this automatically. You don’t need to re-check, but a defensive re-check inside the method costs nothing.

Examples:

// 1. Simple JSON data endpoint
@ConsolePagePlugin.Path("/data")
public void getData(HttpServletRequest request, HttpServletResponse response)
        throws IOException {
    response.setContentType("application/json;charset=UTF-8");
    response.getWriter().write("{\"hello\":\"world\"}");
}

// 2. Multiple path patterns mapped to one method
@ConsolePagePlugin.Path({"/list", "/list/{category}"})
public void listItems(HttpServletRequest request, HttpServletResponse response,
                      @ConsolePagePlugin.PathParam("category") String category) {
    // category will be the URL segment or null if /list was called
}

// 3. Wildcard sub-tree
@ConsolePagePlugin.Path("/admin/**")
public String adminPanel(HttpServletRequest request) {
    return "<h1>Admin Panel</h1>";   // returned String becomes the HTML body
}

// 4. Pure path-variable handler
@ConsolePagePlugin.Path("/form/{id}/edit")
public String editForm(@ConsolePagePlugin.PathParam("id") String id) {
    return renderEditForm(id);
}

The @ConsolePagePlugin.PathParam annotation

@PathParam extracts a variable out of the matched URL path and binds it to a method parameter.

Annotation definition:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface PathParam {
    String value();   // the variable name in the @Path pattern
}
  • The value() must match the placeholder name in the @Path pattern. For @Path("/form/{id}"), use @PathParam("id").

  • Extracted values are always strings. Parse them yourself if you need integers, booleans, etc.

  • If the placeholder is absent from the actually-matched URL (e.g., the variant @Path({"/list", "/list/{cat}"}) was called as /list), the parameter receives null.

Supported @Path method parameter types

Joget’s ConsolePagePluginController injects these parameter types automatically declare them in any order:

Parameter type

Injected value

jakarta.servlet.http.HttpServletRequest

The current request (query params, headers, body)

jakarta.servlet.http.HttpServletResponse

The response (set status, headers, write body)

org.springframework.ui.ModelMap

A model map for view rendering (rarely needed in this pattern; useful if you return a Spring view name as String)

String with @PathParam("name")

Path variable extracted from the URL pattern

Parameter types not in this list receive null. There is no JSON request body binding like Spring’s @RequestBody you read the body of the request yourself if you need to (request.getReader() for POST/PUT bodies).

Routing summary

request → method dispatch

GET /web/console/plugin/<name>
    1. PluginManager loads the plugin by name
    2. plugin.isAuthorized() must return true
    3. No sub-path → call plugin.render(req, resp)
    4. Returned String is wrapped in the console/page/default template

GET /web/console/plugin/<name>/<sub-path>
    1. PluginManager loads the plugin by name
    2. plugin.isAuthorized() must return true
    3. Walk all @Path-annotated methods, AntPathMatcher.match(<sub-path>) each
    4. First match wins → invoke with injected parameters
    5. Method writes directly to response (void) or returns String body

render() the page shell

render() is called when the browser navigates to your page’s base URL. It returns a string of HTML, which Joget wraps in the standard console chrome (header, sidebar, footer).

You have full freedom over what HTML to return. Three common shapes:

  1. Pure server-rendered HTML returns a static or FTL-templated string. Useful for traditional admin pages with no client-side reactivity. Skip Sections 7, 9, 10.
  2. Server-rendered with light JS sprinkles returns HTML plus a few <script> tags using jQuery (Joget bundles it) for AJAX and DOM updates.
  3. SPA shell returns a minimal HTML shell + <script> tags for a JS framework (Vue, React, etc.). The framework takes over a mount point and fetches data via @Path endpoints. This is what the rest of this guide focuses on.

Minimal render() (no JS):

@Override
public String render(HttpServletRequest request, HttpServletResponse response) {
    PluginManager pluginManager = (PluginManager) AppUtil
            .getApplicationContext().getBean("pluginManager");

    Map<String, Object> model = new HashMap<>();
    model.put("currentUser", request.getRemoteUser());

    return pluginManager.getPluginFreeMarkerTemplate(
            model,
            getClassName(),
            "templates/MyDashboard.ftl",
            MESSAGE_PATH);
}

The rest of this section shows the SPA-shell variant, where the CSS / JS files live in src/main/resources/resources/ and are auto-served by Joget at ${contextPath}/plugin/<FQCN>/<path>. You don’t need to write any Java code to serve them,  just reference them by URL.

@Override
public String render(HttpServletRequest request, HttpServletResponse response) {
    PluginManager pluginManager = (PluginManager) AppUtil
            .getApplicationContext().getBean("pluginManager");

    String contextPath = request.getContextPath();

    // Static assets — auto-served from src/main/resources/resources/
    String pluginAssets = contextPath + "/plugin/" + getClassName();
    String appCssUrl    = pluginAssets + "/css/MyDashboard.css";
    String appJsUrl     = pluginAssets + "/js/MyDashboard.js?v=1";  // bump v=N to cache-bust
    String vueUrl       = "https://unpkg.com/vue@3.4.21/dist/vue.global.prod.js";
    // (or bundle locally: pluginAssets + "/js/vue.global.prod.js")

    // Data endpoint — served by the @Path("/data") method on this plugin
    String dataUrl = contextPath + "/web/console/plugin/"
            + encodeName(PLUGIN_NAME) + "/data";

    String css       = "<link rel=\"stylesheet\" href=\"" + appCssUrl + "\">";
    String vueScript = "<script src=\"" + vueUrl + "\"></script>";
    String appScript = "<script src=\"" + appJsUrl + "\"></script>";

    // Variables exposed to the FTL template
    Map<String, Object> data = new HashMap<>();
    data.put("dataUrl", dataUrl);
    data.put("contextPath", contextPath);

    String ftl = pluginManager.getPluginFreeMarkerTemplate(
            data,
            getClassName(),
            "templates/MyDashboard.ftl",
            MESSAGE_PATH);

    return css + ftl + vueScript + appScript;
}

private String encodeName(String name) {
    try {
        return java.net.URLEncoder.encode(name, "UTF-8").replace("+", "%20");
    } catch (Exception e) {
        return name;
    }
}

Two different URL patterns are at play know the difference:

Pattern

Used for

Implementation

${contextPath}/plugin/<FQCN>/<file>

Static assets (CSS, JS, images, fonts)

Auto-served from resources/resources/ no Java code needed

${contextPath}/web/console/plugin/<PluginName>/<path>

Dynamic endpoints (your data API, AJAX calls)

Each method is annotated with @ConsolePagePlugin.Path("/path")

Why URL-encode the plugin name? Because the dynamic endpoint URL is built from getName(), which may contain spaces. The static-asset URL uses getClassName() (fully qualified class name), which is always URL-safe and needs no encoding.

Why the ?v=N query parameter on MyDashboard.js? It is a cache-buster. When your response JSON schema changes incompatibly, bump N so browsers re-fetch the new SPA logic instead of running stale cached JS against new data.

Serving Static Assets (CSS / JS / Images / Fonts)

Joget auto-serves any file placed under src/main/resources/resources/ no Java code, no @Path annotations, no servlet handlers required. This is the same mechanism used by every built-in Joget plugin.

The URL pattern

${request.contextPath}/plugin/<fully.qualified.plugin.class>/<path-inside-resources-resources>

File in your project

URL

src/main/resources/resources/js/MyDashboard.js

${contextPath}/plugin/com.example.myplugin.plugin.MyDashboardPlugin/js/MyDashboard.js

src/main/resources/resources/css/MyDashboard.css

${contextPath}/plugin/com.example.myplugin.plugin.MyDashboardPlugin/css/MyDashboard.css

src/main/resources/resources/images/logo.png

${contextPath}/plugin/com.example.myplugin.plugin.MyDashboardPlugin/images/logo.png

You can reference these URLs directly from your FTL template, your Java render() method, or even your CSS (background-image: url(...)).

Example reference from FTL:

<script src="${request.contextPath}/plugin/com.example.myplugin.plugin.MyDashboardPlugin/js/MyDashboard.js"></script>
<link rel="stylesheet" href="${request.contextPath}/plugin/com.example.myplugin.plugin.MyDashboardPlugin/css/MyDashboard.css">
<img src="${request.contextPath}/plugin/com.example.myplugin.plugin.MyDashboardPlugin/images/logo.png">

When you DO need a @Path method

The auto-serve mechanism only handles static files. You still need @ConsolePagePlugin.Path("…") methods for anything that needs to compute a response:

  • Your data API (/data → returns JSON)
  • File exports (/export.csv → streams generated CSV)
  • Any endpoint that needs to read query parameters or session state

Static CSS/JS/images never need a @Path method.

REST / AJAX Data Endpoint

This is the JSON API your Vue SPA (or any client-side JS) calls. It is just a @ConsolePagePlugin.Path method,  see Section The @ConsolePagePlugin.Path annotation for the annotation reference. This section focuses on the implementation patterns: parameter validation, error responses, multi-tenancy, and async work.

@ConsolePagePlugin.Path("/data")
public void getDashboardData(HttpServletRequest request, HttpServletResponse response)
        throws IOException {

    response.setContentType("application/json;charset=UTF-8");
    response.setHeader("Cache-Control", "no-store");

    try {
        // 1. Parse & validate query params — ALWAYS sanitise user input.
        String filter = SecurityUtil.validateStringInput(request.getParameter("filter"));
        int limit = parseIntParam(request.getParameter("limit"), 50);

        // 2. Authorisation check (in addition to isAuthorized() page-level gate)
        if (!isAuthorized()) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.getWriter().write(new JSONObject()
                    .put("error", "Not authorised").toString());
            return;
        }

        // 3. Do the actual work — call services, query DB, run logic, etc.
        JSONObject payload = buildPayload(filter, limit);

        // 4. Write response
        response.getWriter().write(payload.toString());

    } catch (Exception e) {
        LogUtil.error(getClass().getName(), e, "data endpoint error");
        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        response.getWriter().write(
                new JSONObject().put("error", e.getMessage()).toString());
    }
}

private int parseIntParam(String value, int defaultValue) {
    if (value == null || value.isBlank()) return defaultValue;
    try {
        int v = Integer.parseInt(value.trim());
        return v > 0 ? v : defaultValue;
    } catch (NumberFormatException e) {
        return defaultValue;
    }
}

Why SecurityUtil.validateStringInput()?

It strips dangerous characters and prevents reflected XSS / injection through query parameters. Use it for every string param you read from the request.

Multi-tenant safety: scope by current profile

If your plugin reads tenant-scoped data, use DynamicDataSourceManager.getCurrentProfile() to namespace cache keys and database access. For example, when building a cache key:

String profile = DynamicDataSourceManager.getCurrentProfile();
String cacheKey = profile + "|" + filter + "|" + limit;

Async or long-running endpoints

If the data fetch is slow, run it on a thread pool and use CompletableFuture with orTimeout(...) to bound how long the request will wait. Capture the executor in Activator.stop() to shut it down cleanly.

private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4,
        r -> { Thread t = new Thread(r, "my-plugin-worker"); t.setDaemon(true); return t; });

Internationalisation (i18n)

Joget loads i18n strings from a .properties file referenced by MESSAGE_PATH:

src/main/resources/messages/myPlugin.properties:

MyDashboardPlugin.label=My Vue Dashboard
MyDashboardPlugin.desc=Sample Vue.js admin page plugin.
MyDashboardPlugin.refresh=Refresh

To localise, add suffixed variants: myPlugin_zh_CN.properties, myPlugin_ja_JP.properties, etc.

  • Inside FTL: use ${i18n("MyDashboardPlugin.refresh")} (Joget auto-injects the helper).

Build, Deploy & Hot Reload

Build the JAR

mvn clean package

Output: target/my-vue-plugin-1.0-SNAPSHOT.jar

Deploy

Option A Hot upload via admin UI (fastest for iteration):

  1. Log in to the Joget admin.
  2. Go to Settings > Manage Plugins.
  3. Click Upload Plugin and select your JAR.
  4. Joget hot-reloads the OSGi bundle.
  5. Navigate to the section configured in getLocation() (e.g., Monitor).  Your page should appear in the sidebar.

Option B Drop into app_plugins/:

cp target/my-vue-plugin-1.0-SNAPSHOT.jar /path/to/joget/wflow/app_plugins/

Then restart Joget OR trigger a refresh from the Plugin Manager.

Iteration loop

After the first deploy, the fastest dev cycle is:

  1. Edit Java / JS / CSS / FTL.
  2. mvn clean package.
  3. Upload the new JAR via the Plugin Manager (hot reload).
  4. Hard-refresh the browser (Cmd-Shift-R / Ctrl-Shift-R) to bypass cached app.js.
Pro tip
If you edit only the JS/CSS/FTL, you still need to rebuild the JAR;  those files live inside the bundle JAR’s classpath. There’s no way to edit them in-place after deployment.

Testing

Java unit tests

Place tests in src/test/java/. Standard JUnit 4 or 5 works:

package com.example.myplugin.plugin;

import org.junit.Test;
import static org.junit.Assert.*;

public class MyDashboardPluginTest {
    @Test
    public void testParseIntParamWithValidValue() {
        MyDashboardPlugin plugin = new MyDashboardPlugin();
        // Use reflection or expose package-private helpers to test
    }
}

If your tests need --add-opens for reflection on java.nio (some Joget classes do):

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.5.0</version>
    <configuration>
        <argLine>--add-opens=java.base/java.nio=ALL-UNNAMED</argLine>
    </configuration>
</plugin>

Note: code that depends on the live Joget Spring context (e.g., AppUtil.getApplicationContext()) is hard to unit-test,  extract pure logic into helper classes you can test in isolation, and rely on manual smoke testing for the Spring-bound integration points.

Frontend smoke testing

There is no automated frontend test harness for buildless Vue plugins. Use the browser DevTools instead:

  • Console check for JS errors.
  • Network verify /data returns 200, inspect the JSON.
  • Vue DevTools installs the browser extension; it works with the global build and lets you inspect component state.

Manual checklist after each build

  • Page loads with no console errors
  • GET /data returns 200 (or your documented error code)
  • Filter controls trigger a new /data request
  • The refresh button forces a fresh fetch
  • Empty state renders when there’s no data
  • Error state renders when /data returns 500
  • Authorisation: a non-admin user hitting the URL gets blocked

Common Pitfalls & Best Practices

The dynamic endpoint URL contains spaces: Encode it

getName() returns "My Vue Dashboard", so the dynamic endpoint URL becomes /web/console/plugin/My%20Vue%20Dashboard/data. Always URL-encode PLUGIN_NAME when building these paths in Java; the helper encodeName() shown earlier handles this. Static-asset URLs use getClassName() (fully qualified class name, e.g., com.example.myplugin.plugin.MyDashboardPlugin), which is always URL-safe and needs no encoding.

The browser caches app.js aggressively

If you ship updated JS but the browser shows old behaviour, you forgot to bump the cache-buster (?v=N). Bump it in render() whenever the JS or response shape changes:

String appJsUrl = base + "/app.js?v=" + CURRENT_SCHEMA_VERSION;

Don’t put framework-specific imports in Import-Package

If you embed a library (e.g., org.json), keep it out of the Import-Package; the Embed-Dependency directive already includes it inside the bundle. Adding it to Import-Package causes OSGi to try to resolve it externally, which fails.

Validate every query parameter

Always run user input through SecurityUtil.validateStringInput(...) before using it. Don’t trust query strings.

Always set Cache-Control: no-store on the data endpoint

Without it, the browser may cache JSON responses indefinitely. The user clicks “Refresh” and sees stale data. Joget handles caching headers for auto-served static assets (CSS/JS/images) automatically you only need to manage Cache-Control on your own @Path methods. The recommended pattern: no-store on /data and any other JSON endpoint that returns live state.

Don’t leak threads on hot reload

If you create an ExecutorService or a scheduled task, shut it down in Activator.stop(). Otherwise, every JAR upload doubles the daemon thread count until the JVM is restarted.

Keep the FTL shell minimal

All UI logic belongs in Vue. The FTL should only: - Set window.IO_CONFIG - Provide the <div id="..."> mount point - Render the custom-element placeholder (<io-dashboard></io-dashboard>)

Putting actual reactive content in FTL leads to confusing hydration behaviour and harder debugging.

Match isAuthorized() checks on the data endpoint

isAuthorized() gates the page render, but the data endpoint is a separate URL that anyone with the URL can hit. Re-check authorisation in the /data method:

@ConsolePagePlugin.Path("/data")
public void getDashboardData(HttpServletRequest request, HttpServletResponse response) throws IOException {
    if (!isAuthorized()) {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        return;
    }
    // ...
}

Use schema versions for caching

If you cache JSON responses to disk, include a "schema_version": N field. On read, reject cached entries with a lower version; otherwise, a post-upgrade dashboard renders stale v1 data through the v2 UI.

Extending Where to Add What

You want to…

Edit…

Add a new dashboard section

MyDashboard.js (Vue template) + the /data JSON response builder in Java

Add a new query filter

MyDashboard.js (input + fetchData) + parse param in getDashboardData()

Add a new REST endpoint (e.g., /export)

Add another @ConsolePagePlugin.Path("/export") method in the plugin class

Change icon or sidebar location

getPluginIcon() and getLocation()

Restrict to a different role

isAuthorized() (also re-check in every endpoint method)

Add a translation

Add a new .properties file with locale suffix in messages/

Embed a 3rd-party library (chart.js, lodash)

Drop the JS file in resources/resources/js/, add a <script> tag in render() pointing to the auto-served URL no @Path endpoint needed

Glossary

Term

Definition

OSGi bundle

A JAR with extra MANIFEST.MF metadata describing what packages it imports/exports. Joget plugins are OSGi bundles.

BND / maven-bundle-plugin

Builds the OSGi bundle JAR from your Maven project; generates the MANIFEST.MF from the <instructions> block.

ConsolePagePlugin

The Joget plugin type that renders a page in the admin console. Subclass ConsolePagePluginAbstract.

@ConsolePagePlugin.Path

An annotation that maps a sub-URL under your plugin’s path to a Java method. Like a tiny built-in router.

AppUtil

Joget utility for resource access (readPluginResource) and Spring bean lookup (getApplicationContext).

PluginManager

Joget Spring bean for getting Freemarker templates and other plugins.

SetupManager

Joget Spring bean for accessing configuration directories (getBaseDirectory()).

DynamicDataSourceManager

Tenant-aware data source selector; getCurrentProfile() returns the current tenant ID.

FreeMarker (FTL)

Server-side template language Joget uses for HTML rendering.

Vue 3 global build

vue.global.prod.js exposes Vue as a window global. Let's you use Vue without a build step.

Appendix A Minimum Viable File Set

The smallest working Vue.js + Joget plugin needs exactly these files:

my-vue-plugin/
├── pom.xml
└── src/main/
    ├── java/com/example/myplugin/
    │   ├── Activator.java
    │   └── plugin/MyDashboardPlugin.java
    └── resources/
        ├── messages/myPlugin.properties
        ├── templates/
        │   └── MyDashboard.ftl                  ← FTL shell
        └── resources/                           ← auto-served by Joget
            ├── js/MyDashboard.js
            └── css/MyDashboard.css

That’s 7 files. Build with mvn clean package, upload the JAR via Plugin Manager, navigate to the configured section in the Joget admin console, and done.

Created by Nur Baizura Badrul Sham Last modified by Debanraj Ravindran on Jun 22, 2026