Custom HTML element.
/**
* Cross-Window Bulk Selector for Joget Form Grids
* Compatible with Joget 7+ | Requires jQuery (bundled)
*/
(function () {
'use strict';
const CONFIG = {
triggerId: 'bulk-import-trigger',
listEndpoint: '/jw/web/userview/procurement/v/_/A1B2C3D4E5F6?embed=true',
gridFieldId: 'material_request_grid',
popupDimensions: { width: 960, height: 720 },
listTableSelector: '#master_inventory_table tbody',
checkboxSelector: 'input[type="checkbox"].row-select'
};
class BulkDataBridge {
private popupRef: Window | null = null;
private parentForm: Document;
constructor() {
this.parentForm = document;
this.initTrigger();
}
private initTrigger(): void {
const triggerBtn = document.getElementById(CONFIG.triggerId);
if (!triggerBtn) return;
triggerBtn.addEventListener('click', () => this.openSelectionPopup());
}
private openSelectionPopup(): void {
const features = `width=${CONFIG.popupDimensions.width},height=${CONFIG.popupDimensions.height},scrollbars=yes,resizable=yes`;
this.popupRef = window.open('', '_blank', features);
if (!this.popupRef) {
console.error('Popup blocked or failed to initialize.');
return;
}
this.renderPopupUI(this.popupRef);
this.attachPopupListeners(this.popupRef);
}
private renderPopupUI(win: Window): void {
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 12px; background: #f8f9fa; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.action-btn { padding: 8px 16px; background: #0d6efd; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.action-btn:hover { background: #0b5ed7; }
iframe { width: 100%; height: 600px; border: 1px solid #dee2e6; border-radius: 4px; }
</style>
</head>
<body>
<div class="toolbar">
<strong>Select Records</strong>
<button id="confirm-selection" class="action-btn">Inject Selected</button>
</div>
<iframe id="data-source" src="${CONFIG.listEndpoint}" title="Joget List View"></iframe>
</body>
</html>
`;
win.document.open();
win.document.write(html);
win.document.close();
}
private attachPopupListeners(win: Window): void {
const confirmBtn = win.document.getElementById('confirm-selection');
if (!confirmBtn) return;
confirmBtn.addEventListener('click', () => {
this.extractAndValidate(win);
});
}
private extractAndValidate(popupWin: Window): void {
try {
const iframeDoc = popupWin.document.getElementById('data-source')?.contentWindow?.document;
if (!iframeDoc) throw new Error('Iframe content not accessible.');
const checkedRows = iframeDoc.querySelectorAll(`${CONFIG.listTableSelector} tr:has(${CONFIG.checkboxSelector}:checked)`);
if (checkedRows.length === 0) {
alert('No records selected.');
return;
}
const existingKeys = this.getExistingParentKeys();
const gridMutationFn = this.resolveGridMutationFunction();
if (typeof gridMutationFn !== 'function') {
console.error('Joget grid mutation function not found.');
return;
}
checkedRows.forEach((row) => {
const cells = row.querySelectorAll('td');
const recordKey = cells[1]?.textContent?.trim() || '';
if (!existingKeys.has(recordKey)) {
const payload = this.mapRowToPayload(cells);
gridMutationFn({ result: JSON.stringify(payload) });
}
});
popupWin.close();
} catch (error) {
console.error('Data extraction failed:', error);
}
}
private getExistingParentKeys(): Set<string> {
const keys = new Set<string>();
const gridTable = this.parentForm.querySelector(`[name="${CONFIG.gridFieldId}"] .tablesaw`);
if (!gridTable) return keys;
gridTable.querySelectorAll('tr:not(:first-child)').forEach((row) => {
const keyCell = row.querySelector(`#${CONFIG.gridFieldId}_item_code`);
if (keyCell?.textContent) keys.add(keyCell.textContent.trim());
});
return keys;
}
private resolveGridMutationFunction(): Function | undefined {
const fieldEl = (window as any).FormUtil?.getField(CONFIG.gridFieldId);
if (!fieldEl?.attr) return undefined;
const fnName = `${fieldEl.attr('id')}_add`;
return (window as any)[fnName];
}
private mapRowToPayload(cells: NodeListOf<HTMLTableCellElement>): Record<string, string> {
return {
item_code: cells[1]?.textContent?.trim() || '',
item_desc: cells[2]?.textContent?.trim() || '',
unit_price: cells[4]?.textContent?.trim() || '',
supplier_ref: cells[6]?.textContent?.trim() || ''
};
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new BulkDataBridge());
} else {
new BulkDataBridge();
}
})();
Why This Structure Works
- Encapsulation: The IIFE prevents global namespace pollution, critical in Joget environments where multiple custom scripts may coexist.
- Explicit Configuration: Centralizing selectors and endpoints in
CONFIG makes the script portable across different forms and list views.
- Safe DOM Traversal: Using
:has() and explicit class selectors reduces brittleness compared to positional td:eq() indexing.
- Set-Based Validation:
Set provides O(1) lookup for duplicate checking, significantly improving performance when injecting dozens of records.
- Dynamic Function Resolution:
FormUtil.getField() ensures the script adapts to Joget's runtime field ID generation, preventing hardcoded mismatches.
Pitfall Guide
1. Cross-Origin Security Blocks
Explanation: Browsers enforce same-origin policy on contentWindow.document. If the iframe URL differs in protocol, domain, or port, DOM access throws a security exception.
Fix: Always use relative URLs or ensure the Joget instance runs on a single domain. Avoid proxying or CDN mismatches. Test in incognito to rule out extension interference.
2. Iframe Load Timing Mismatch
Explanation: Attaching event listeners before the iframe finishes rendering results in null references or empty selections.
Fix: Implement a lightweight polling mechanism or attach the submit handler only after the iframe's load event fires. Alternatively, defer button activation until iframe.contentDocument.readyState === 'complete'.
3. Joget Field ID Drift
Explanation: Joget regenerates field IDs during form versioning or cloning. Hardcoding [fieldId]_add breaks after deployment.
Fix: Always resolve the mutation function at runtime using FormUtil.getField(). Cache the result only within the current form session, not across page reloads.
4. Selector Brittleness from Column Reordering
Explanation: Using td:eq(2) assumes a fixed column order. List view customizations or user-driven column rearrangements shift indices.
Fix: Target cells by data attributes, header text matching, or stable CSS classes. If unavailable, parse the header row dynamically to map column names to indices before extraction.
Explanation: Modern browsers block window.open() if it's not triggered synchronously within a user gesture. Async delays or promise chains cause silent failures.
Fix: Call window.open() directly inside the click handler. Populate the popup content immediately after creation. Avoid setTimeout or fetch delays before opening.
6. JSON Payload Schema Mismatch
Explanation: Joget's grid _add function expects keys that exactly match the grid column field names. Typos or missing fields cause silent injection failures or validation errors.
Fix: Inspect the grid's JSON schema via browser dev tools or Joget's form preview. Validate payload keys against the grid's column definitions before invocation. Add console warnings for missing fields.
Explanation: Users closing the popup via the OS window manager (instead of the script) leaves event listeners and DOM references attached to the parent.
Fix: Attach a beforeunload listener to the popup that nullifies the popupRef. Use try/catch around cross-window calls to gracefully handle already-closed windows.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| < 50 records, static list | Native Joget Multi-Select | Simpler, no cross-window complexity | Low |
| 50–500 records, frequent filtering | Iframe Popup Bridge | Leverages existing list UI, reduces manual entry | Medium |
| > 500 records, real-time sync | Joget REST API + Custom Plugin | Avoids DOM scraping, enables server-side validation | High |
| Multi-tenant / SaaS deployment | Iframe Popup Bridge | Zero backend changes, works across all tenants | Low |
| Strict security / CSP enforced | Native Multi-Select + Server Validation | Avoids cross-window DOM access, complies with strict policies | Medium |
Configuration Template
Copy this template into a Joget Custom HTML element. Update the CONFIG object to match your environment.
<button id="bulk-import-trigger" class="form-button">Open Master List</button>
<script>
(function() {
const CFG = {
triggerId: 'bulk-import-trigger',
listUrl: '/jw/web/userview/YOUR_VIEW/v/_/YOUR_LIST_ID?embed=true',
gridId: 'YOUR_GRID_FIELD_ID',
width: 960,
height: 720,
tableSelector: '#YOUR_LIST_TABLE_ID tbody',
checkSelector: 'input[type="checkbox"]'
};
const btn = document.getElementById(CFG.triggerId);
if(!btn) return;
btn.onclick = function() {
const win = window.open('', '_blank', `width=${CFG.width},height=${CFG.height}`);
if(!win) return alert('Popup blocked');
win.document.write(`
<style>body{font-family:system-ui;padding:12px;background:#f5f5f5}
.bar{margin-bottom:8px;display:flex;justify-content:space-between}
.btn{padding:8px 14px;background:#0d6efd;color:#fff;border:none;border-radius:4px;cursor:pointer}
iframe{width:100%;height:600px;border:1px solid #ccc}</style>
<div class="bar"><strong>Select Items</strong><button id="inject" class="btn">Add to Grid</button></div>
<iframe id="src" src="${CFG.listUrl}"></iframe>
`);
win.document.close();
win.document.getElementById('inject').onclick = function() {
try {
const doc = win.document.getElementById('src').contentWindow.document;
const rows = doc.querySelectorAll(`${CFG.tableSelector} tr:has(${CFG.checkSelector}:checked)`);
const existing = new Set();
document.querySelectorAll(`[name="${CFG.gridId}"] .tablesaw tr:not(:first-child)`).forEach(r => {
const v = r.querySelector(`#${CFG.gridId}_key_field`)?.textContent?.trim();
if(v) existing.add(v);
});
const field = window.FormUtil.getField(CFG.gridId);
const addFn = window[field.attr('id') + '_add'];
if(typeof addFn !== 'function') return console.error('Grid function missing');
rows.forEach(tr => {
const cells = tr.querySelectorAll('td');
const key = cells[1]?.textContent?.trim() || '';
if(!existing.has(key)) {
addFn({ result: JSON.stringify({
key_field: key,
desc_field: cells[2]?.textContent?.trim() || '',
price_field: cells[4]?.textContent?.trim() || ''
})});
}
});
win.close();
} catch(e) { console.error('Injection failed', e); }
};
};
})();
</script>
Quick Start Guide
- Identify Target Components: Locate your Joget list view ID and form grid field ID. Note the exact column field names in the grid's JSON schema.
- Insert Custom HTML: Add a Custom HTML element to your form. Paste the configuration template and update
CFG values to match your environment.
- Test Popup Flow: Click the trigger button. Verify the popup opens, the list loads, and checkboxes are selectable.
- Validate Injection: Select 2–3 rows, click "Add to Grid", and confirm records appear without duplicates. Check browser console for errors.
- Deploy & Monitor: Publish the form. Monitor user sessions for popup blocker complaints or selector mismatches. Adjust
tableSelector if list view updates change DOM structure.