Case Study: Implementing a “Print Voucher” button. This guide walks you through adding a custom button to the iDempiere Window Toolbar using OSGi.

Architecture Overview

Custom Toolbar buttons in iDempiere leverage the OSGi Declarative Services (DS) mechanism. This involves registering a Java class from your plugin as an IAction service. When the ADWindowToolbar loads, it reads records from the AD_ToolBarButton table where IsCustomization=Y. It then uses OSGi to locate the corresponding IAction implementation and dynamically creates the button.

graph LR
    A["AD_ToolBarButton<br/>(資料庫設定)"] --> B["ADWindowToolbar<br/>(載入按鈕)"]
    B --> C["Actions.getAction()<br/>(OSGi 查詢)"]
    C --> D["IAction 實作<br/>(你的插件)"]
    B --> E["Actions.getActionImage()<br/>(載入圖示)"]
    E --> F["action/images/<br/>{actionId}24.png"]
    D --> G["ToolbarCustomButton<br/>(事件綁定)"]
    G -->|"onClick"| D

Core Components

ComponentLocationDescription
IAction Interfaceorg.adempiere.webui.action.IActionThe interface that must be implemented by custom buttons.
Actions Utilityorg.adempiere.webui.action.ActionsHelper class to look up IAction services and load icons via OSGi.
ToolbarCustomButtonorg.adempiere.webui.adwindow.ToolbarCustomButtonThe button model responsible for binding click events and calling IAction.execute().
ADWindowToolbarorg.adempiere.webui.adwindow.ADWindowToolbarLoads AD_ToolBarButton data and instantiates the buttons.
AD_ToolBarButtonDatabase TableDefines the button name, Action Class, display logic, etc.

Development Steps

Step 1: Implement the IAction Interface

Create a Java class that implements org.adempiere.webui.action.IAction.

File Location

src/tw/ninniku/accounting/btn/VoucherAction.java

IAction Interface Definition

public interface IAction {
    /** Triggered when the button is clicked */
    void execute(Object target);

    /** (Optional) Customize button appearance */
    default void decorate(Toolbarbutton toolbarButton) {}

    /** (Optional) Use Font Icon, return CSS class */
    default String getIconSclass() { return ""; }

    /** (Optional) Dynamically enable/disable based on Tab state */
    default void updateToolbarCustomBtn(Toolbarbutton toolbarButton,
            IADTabpanel tabPanel, boolean changed, boolean readOnly) {}
}

Full Example: VoucherAction.java

package tw.ninniku.accounting.btn;

import org.adempiere.util.IProcessUI;
import org.adempiere.util.ProcessUtil;
import org.adempiere.webui.action.IAction;
import org.adempiere.webui.adwindow.ADWindow;
import org.adempiere.webui.adwindow.ADWindowContent;
import org.compiere.model.GridTab;
import org.compiere.model.MPInstance;
import org.compiere.model.MProcess;
import org.compiere.model.MTable;
import org.compiere.model.PO;
import org.compiere.process.ProcessInfo;
import org.compiere.process.ProcessInfoParameter;
import org.compiere.util.AdempiereUserError;
import org.compiere.util.CLogger;
import org.compiere.util.DB;
import org.compiere.util.Env;
import org.compiere.util.Msg;
import org.compiere.util.Trx;

import java.sql.CallableStatement;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;

/**
 * Action to generate and print accounting voucher report.
 * Validates document posting status and launches the ACCOUNTING_FACT_JR
 * Jasper report.
 */
public class VoucherAction implements IAction {

    private static final CLogger log = CLogger.getCLogger(VoucherAction.class);

    /** Process value for the accounting fact Jasper report */
    private static final String PROCESS_VALUE = "ACCOUNTING_FACT_JR";

    private int adTableId;
    private int recordId;

    @Override
    public void execute(Object target) {
        // 1. Get current GridTab from ADWindow
        ADWindow window = (ADWindow) target;
        ADWindowContent content = window.getADWindowContent();
        GridTab tab = content.getActiveGridTab();

        adTableId = tab.getAD_Table_ID();
        recordId = tab.getRecord_ID();

        // 2. Validate if the document is posted
        validateDocument(adTableId, recordId);

        // 3. Print Report
        printReport();
    }

    /**
     * Validate document is posted (Posted=Y).
     * Throws user error if 'Posted' column is missing or false.
     */
    private void validateDocument(int adTableId, int recordId) {
        MTable mTable = MTable.get(Env.getCtx(), adTableId);
        PO po = mTable.getPO(recordId, null);

        if (po.get_ColumnIndex("Posted") < 0) {
            throw new AdempiereUserError(
                Msg.getMsg(Env.getCtx(), "VoucherNotSupported"));
        }
        if (!po.get_ValueAsBoolean("Posted")) {
            String docId = Msg.parseTranslation(
                Env.getCtx(), "@" + po.get_TableName() + "_ID@");
            throw new AdempiereUserError(
                Msg.getMsg(Env.getCtx(), "DocumentNotPosted",
                    new Object[]{docId}));
        }
    }

    /**
     * Create ProcessInfo and call Jasper Report.
     */
    private void printReport() {
        int adProcessId = MProcess.getProcess_ID(PROCESS_VALUE, null);
        MProcess process = MProcess.get(Env.getCtx(), adProcessId);

        MPInstance pInstance = new MPInstance(process, recordId);
        pInstance.createParameter(10, "AD_Table_ID", adTableId);
        pInstance.createParameter(20, "RECORD_ID", recordId);

        ProcessInfo pi = new ProcessInfo(
            PROCESS_VALUE, adProcessId, adTableId, recordId);
        pi.setAD_User_ID(Env.getAD_User_ID(Env.getCtx()));
        pi.setAD_Client_ID(Env.getAD_Client_ID(Env.getCtx()));
        pi.setAD_PInstance_ID(pInstance.getAD_PInstance_ID());
        pi.setRecord_ID(recordId);

        Trx trx = Trx.get(Trx.createTrxName(), true);
        trx.start();
        try {
            // Get Fact Number logic (Example SQL)
            String dateAcctSql =
                "SELECT DateAcct FROM Fact_Acct "
                + "WHERE AD_Table_ID=? AND Record_ID=?";
            Timestamp dateAcct = DB.getSQLValueTS(
                trx.getTrxName(), dateAcctSql, adTableId, recordId);

            String factNoSql =
                "SELECT fi_get_fact_documentno(?, ?, ?)";
            String factNo = DB.getSQLValueString(
                trx.getTrxName(), factNoSql,
                adTableId, recordId, dateAcct);

            pInstance.createParameter(30, "FactNo", factNo);

            // Assemble Process Parameters
            List<ProcessInfoParameter> params = new ArrayList<>();
            params.add(new ProcessInfoParameter(
                "AD_PInstance_ID",
                pInstance.getAD_PInstance_ID(), null, null, null));
            params.add(new ProcessInfoParameter(
                "AD_Table_ID", adTableId, null, null, null));
            params.add(new ProcessInfoParameter(
                "RECORD_ID", recordId, null, null, null));
            params.add(new ProcessInfoParameter(
                "CURRENT_LANG",
                Env.getCtx().getProperty("#Locale"),
                null, null, null));
            params.add(new ProcessInfoParameter(
                "FactNo", factNo, null, null, null));

            pi.setParameter(
                params.toArray(new ProcessInfoParameter[0]));
            pi.setPrintPreview(true);

            // Call Stored Procedure (If defined)
            String proc = process.getProcedureName();
            if (proc != null && !proc.isEmpty()) {
                CallableStatement cstmt = null;
                try {
                    cstmt = DB.prepareCall(
                        "{call " + proc + "(?)}",
                        1, trx.getTrxName());
                    cstmt.setInt(1, pi.getAD_PInstance_ID());
                    cstmt.executeUpdate();
                } catch (Exception e) {
                    log.log(Level.SEVERE, e.getLocalizedMessage(), e);
                } finally {
                    DB.close(cstmt);
                }
            }

            // Execute Jasper Report
            IProcessUI pm = Env.getProcessUI(Env.getCtx());
            ProcessUtil.startJavaProcess(
                Env.getCtx(), pi, trx, true, pm);

            trx.commit();
        } catch (Exception e) {
            trx.rollback();
            log.log(Level.SEVERE, "Error in VoucherAction", e);
            throw new AdempiereUserError(
                Msg.getMsg(Env.getCtx(), "VoucherGenerationError")
                + ": " + e.getMessage());
        } finally {
            trx.close();
        }
    }
}

Tip: The target parameter in execute(Object target) is the ADWindow instance. You can retrieve all information about the current window (Table ID, Record ID, Field Values) via ADWindow → ADWindowContent → GridTab.

Step 2: Create Button Icon

The icon file naming convention must strictly follow this format:

{OSGi component name}24.png

For example, if your component name is tw.ninniku.accounting.btn.VoucherAction, the icon filename must be:

tw.ninniku.accounting.btn.VoucherAction24.png

File Placement

src/action/images/tw.ninniku.accounting.btn.VoucherAction24.png


Important: The path must start with action/images/ (relative to the classpath), as Actions.java has the path /action/images/ hardcoded. Recommended image size is 24×24 pixels PNG.

Icon Loading Order

Actions.getActionImage() searches for icons in the following order:

  1. Current Theme: /theme/{current-theme}/{actionId}24.png
  2. Plugin Theme: /action/images/{current-theme}/{actionId}24.png
  3. Plugin Default: /action/images/default/{actionId}24.png
  4. Plugin Root: /action/images/{actionId}24.png (Most common)

Step 3: Create OSGi DS XML Configuration

Register your Java class as an IAction OSGi service via Declarative Services XML.

File Location

OSGI-INF/tw.ninniku.accounting.btn.VoucherAction.xml

XML

<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
    name="tw.ninniku.accounting.btn.VoucherAction">
   <implementation class="tw.ninniku.accounting.btn.VoucherAction"/>
   <service>
      <provide interface="org.adempiere.webui.action.IAction"/>
   </service>
</scr:component>

Important: The name attribute is the actionId. iDempiere uses this value to:

Call Actions.getAction(actionId) to lookup the OSGi service.

Concatenate actionId + “24.png” to find the icon.

Match the AD_ToolBarButton.ActionClassName field in the database.

Step 4: Configure MANIFEST.MF

Add the necessary configurations to your OSGi Bundle Manifest:

# 1. Register Service-Component (DS XML)
Service-Component: ...,
 OSGI-INF/tw.ninniku.accounting.btn.VoucherAction.xml

# 2. Import the IAction package
Import-Package: ...,
 org.adempiere.webui.action,
 org.adempiere.webui.adwindow

# 3. Export action/images package so iDempiere can load icons via classloader
Export-Package: action.imagesProperties<br>

Step 5: Configure build.properties

Ensure the OSGI-INF XML and src/ directory are included in the build output:

Properties

bin.includes = META-INF/,\
               .,\
               OSGI-INF/tw.ninniku.accounting.btn.VoucherAction.xml,\
               src/
source.. = src/
src.includes = src/
output.. = bin/

Step 6: Add AD_ToolBarButton Database Record

Insert a record into the iDempiere database to add the custom button to the Toolbar:

INSERT INTO AD_ToolBarButton (
    AD_ToolBarButton_ID,
    AD_Client_ID,
    AD_Org_ID,
    IsActive,
    Created, CreatedBy, Updated, UpdatedBy,
    Name,
    ComponentName,
    IsCustomization,
    ActionClassName,
    AD_ToolBarButton_UU,
    Action,
    SeqNo,
    IsShowMore,
    IsAddSeparator,
    KeyStroke_KeyCode,
    KeyStroke_Modifiers
) VALUES (
    (SELECT COALESCE(MAX(AD_ToolBarButton_ID),0)+1
       FROM AD_ToolBarButton),  -- Auto-generate ID
    0,                          -- AD_Client_ID (System)
    0,                          -- AD_Org_ID
    'Y',                        -- IsActive
    NOW(), 100, NOW(), 100,     -- Timestamps
    'Voucher',                  -- Button Name
    'Voucher',                  -- Component Name
    'Y',                        -- ★ Mark as Customization
    'tw.ninniku.accounting.btn.VoucherAction',  -- ★ Must match OSGi service name
    generate_uuid(),            -- UUID
    'W',                        -- Action type: W = Window
    999,                        -- Sequence (Low priority)
    'N',                        -- Show in "More" menu?
    'N',                        -- Add separator?
    0,                          -- Shortcut KeyCode (0=None)
    0                           -- Modifiers
);

Caution: The ActionClassName value must exactly match the name attribute in the OSGI-INF XML; otherwise, iDempiere cannot find the corresponding IAction service.

By Ray Lee (System Analyst)

iDempeire ERP Contributor, 經濟部中小企業處財務管理顧問 李寶瑞

Leave a Reply

Your email address will not be published. Required fields are marked *