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
| Component | Location | Description |
| IAction Interface | org.adempiere.webui.action.IAction | The interface that must be implemented by custom buttons. |
| Actions Utility | org.adempiere.webui.action.Actions | Helper class to look up IAction services and load icons via OSGi. |
| ToolbarCustomButton | org.adempiere.webui.adwindow.ToolbarCustomButton | The button model responsible for binding click events and calling IAction.execute(). |
| ADWindowToolbar | org.adempiere.webui.adwindow.ADWindowToolbar | Loads AD_ToolBarButton data and instantiates the buttons. |
| AD_ToolBarButton | Database Table | Defines 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.javaIAction 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.pngFor example, if your component name is tw.ninniku.accounting.btn.VoucherAction, the icon filename must be:
tw.ninniku.accounting.btn.VoucherAction24.pngFile 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:
- Current Theme: /theme/{current-theme}/{actionId}24.png
- Plugin Theme: /action/images/{current-theme}/{actionId}24.png
- Plugin Default: /action/images/default/{actionId}24.png
- 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.xmlXML
<?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.