tion to prevent memory leaks and handle dynamically rendered elements.
3. Execute Client Validation: Check for null inputs, enforce uniqueness constraints, and validate row limits before attempting mutation.
4. Construct Payload: Map validated inputs to the grid's expected JSON structure. Include metadata like row numbering and composite keys.
5. Inject via API: Call the platform's row addition method, passing the serialized payload. The API handles DOM insertion and state synchronization.
6. Handle Post-Injection State: Verify the row was added, trigger recalculation routines, and reset UI state for the next entry.
TypeScript Implementation
interface GridRowPayload {
task_category: string;
primary_assignee_label: string;
secondary_assignee_label: string;
composite_key: string;
sequence_index: number;
}
class GuidedGridController {
private readonly gridId: string = "workflow_task_grid";
private readonly maxRows: number = 10;
private readonly addBtnSelector: string = "#inject_task_row";
constructor() {
this.initialize();
}
private initialize(): void {
$(document).ready(() => {
this.bindAddRowListener();
this.bindGridChangeHandler();
});
}
private bindAddRowListener(): void {
$(document).on("click", this.addBtnSelector, () => {
const category = $("[name=task_category]").val() as string;
const primaryId = $("[name=primary_assignee]").val() as string;
const secondaryId = $("[name=secondary_assignee]").val() as string;
if (!this.validateInputs(category, primaryId, secondaryId)) return;
if (!this.enforceRowLimit()) return;
const payload = this.buildPayload(category, primaryId, secondaryId);
this.injectRow(payload);
});
}
private validateInputs(category: string, primary: string, secondary: string): boolean {
if (!category || !primary || !secondary) {
alert("All selection fields are required.");
return false;
}
if (primary === secondary) {
alert("Primary and secondary assignees must differ.");
return false;
}
return true;
}
private enforceRowLimit(): boolean {
const currentCount = $(`[name=${this.gridId}] table tbody tr:not(:first)`).length;
if (currentCount >= this.maxRows) {
alert(`Maximum of ${this.maxRows} rows permitted.`);
return false;
}
return true;
}
private buildPayload(category: string, primaryId: string, secondaryId: string): GridRowPayload {
const primaryLabel = $(`[name=primary_assignee] a span`).text().trim();
const secondaryLabel = $(`[name=secondary_assignee] a span`).text().trim();
const currentCount = $(`[name=${this.gridId}] table tbody tr:not(:first)`).length;
return {
task_category: category,
primary_assignee_label: primaryLabel,
secondary_assignee_label: secondaryLabel,
composite_key: `${category}_${primaryId}`,
sequence_index: currentCount + 1
};
}
private injectRow(payload: GridRowPayload): void {
const gridField = FormUtil.getField(this.gridId);
const addMethod = window[`${gridField.attr("id")}_add`] as Function | undefined;
if (typeof addMethod !== "function") {
console.error("Grid injection method not available.");
return;
}
const args: { result: string } = {
result: JSON.stringify(payload)
};
addMethod(args);
this.verifyInjection(payload.composite_key);
}
private verifyInjection(compositeKey: string): void {
const rows = $(`[name=${this.gridId}] table tbody tr:not(:first)`);
const isDuplicate = rows.toArray().some(row => {
const cellData = JSON.parse($(row).find("textarea").val() || "{}");
return cellData.composite_key === compositeKey;
});
if (isDuplicate) {
alert("Duplicate entry detected. Row not added.");
}
}
private bindGridChangeHandler(): void {
$(`[name=${this.gridId}]`).on("change", () => {
$(`[name=${this.gridId}] table tbody tr`).not(":first").each((index, row) => {
this.recalculateSequence(row, index + 1);
});
});
}
private recalculateSequence(row: HTMLElement, newIndex: number): void {
const gridField = FormUtil.getField(this.gridId);
const cellData = JSON.parse($(row).find("textarea").val() || "{}");
cellData.sequence_index = newIndex;
$(gridField).enterpriseformgrid(
"fillValue",
gridField,
row,
JSON.stringify(cellData)
);
}
}
// Initialize controller
new GuidedGridController();
Architecture Decisions and Rationale
Class-Based Encapsulation: The logic is wrapped in a TypeScript class to prevent global namespace pollution and enable dependency injection for testing. This is critical in low-code environments where multiple form scripts coexist.
Event Delegation: Using $(document).on("click", ...) instead of direct binding ensures the listener survives partial form refreshes or dynamic section toggles, which are common in workflow forms.
Payload Construction: The grid expects a specific JSON structure. By mapping inputs to a typed interface (GridRowPayload), we enforce compile-time safety and make the data contract explicit. The composite_key field acts as a client-side uniqueness guard before submission.
API-Driven Injection: Calling window[gridId_add] with a serialized result object is the only supported method for row injection. Direct DOM insertion bypasses Joget's internal state machine, causing the hidden <textarea> payload to desynchronize from the visible table.
Post-Injection Verification: The verifyInjection method checks the actual serialized state rather than relying on DOM length. This catches race conditions where the API might reject a row due to internal validation rules.
Sequence Recalculation: Binding to the grid's change event ensures row numbering stays consistent after manual deletions or drag-and-drop reordering. The fillValue method updates the hidden payload without triggering redundant DOM reflows.
Pitfall Guide
1. Direct DOM Mutation
Explanation: Developers often append <tr> elements directly to the grid's <tbody>. This updates the visual table but leaves the hidden JSON payload untouched, resulting in empty rows during workflow submission.
Fix: Always use FormUtil.getField() and the platform's enterpriseformgrid API to mutate state. Never touch the DOM directly for data persistence.
2. Ignoring the Hidden JSON Payload
Explanation: Joget grids store cell data in a <textarea> as a JSON string. If you modify visible inputs without updating this payload, the workflow engine receives stale or missing data.
Fix: Parse the existing JSON, apply mutations, and re-serialize using fillValue. Treat the textarea as the source of truth, not the visible inputs.
Explanation: Attaching event listeners before the grid component finishes initialization causes undefined errors when calling FormUtil.getField().
Fix: Wrap initialization in $(document).ready() or use platform-specific form load hooks. Add defensive checks for API availability before invoking methods.
4. Hardcoded Business Limits
Explanation: Embedding row limits (e.g., maxRows = 10) directly in client code makes them impossible to adjust without redeployment. It also bypasses server-side enforcement.
Fix: Externalize limits to form properties or environment variables. Implement client-side guards for UX, but always validate limits on the server before workflow progression.
5. Bypassing Server-Side Enforcement
Explanation: Client-side validation can be disabled or manipulated. Relying solely on JavaScript for uniqueness, authorization, or business rules creates security vulnerabilities.
Fix: Treat client validation as a UX enhancement. Implement identical rules in workflow validators, custom plugins, or API endpoints. Log rejected attempts for audit trails.
6. Event Listener Memory Leaks
Explanation: Re-initializing controllers without cleaning up previous listeners causes duplicate executions and performance degradation.
Fix: Use event delegation, namespace events (click.gridController), and implement a destroy() method that calls off() when forms are dynamically unloaded.
7. Breaking Joget's Validation Chain
Explanation: Programmatically adding rows can skip built-in field validations, allowing malformed data to enter the grid.
Fix: Trigger platform validation methods after injection, or manually validate each cell against the grid's schema before calling the add method. Never assume programmatic insertion bypasses validation requirements.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple guided entry with fixed rules | Client-side API injection | Fastest UX, minimal server load | Low (development time) |
| Multi-step workflow with conditional logic | Server-side preprocessing + API sync | Ensures state consistency across steps | Medium (infrastructure) |
| High-security compliance environment | Server-side validation only | Eliminates client tampering surface | High (latency, dev overhead) |
| Dynamic grid with drag-and-drop reordering | API injection + change event listeners | Maintains sequence integrity automatically | Low-Medium |
Configuration Template
// grid-config.ts
export const GRID_CONFIG = {
componentId: "workflow_task_grid",
maxRows: 10,
validationRules: {
requireUniqueAssignees: true,
allowEmptyCategory: false,
sequenceAutoRenumber: true
},
payloadSchema: {
task_category: "string",
primary_assignee_label: "string",
secondary_assignee_label: "string",
composite_key: "string",
sequence_index: "number"
}
};
// Usage in controller
import { GRID_CONFIG } from "./grid-config";
class GuidedGridController {
private readonly config = GRID_CONFIG;
// ... implementation references this.config.maxRows, etc.
}
Quick Start Guide
- Identify Grid Component: Open the Joget form designer, select the Enterprise Form Grid, and note its
name attribute. Replace workflow_task_grid in the template with this value.
- Map Input Fields: Ensure your form contains the selector fields (
task_category, primary_assignee, secondary_assignee). Verify their name attributes match the controller expectations.
- Inject Script: Add the TypeScript/JavaScript controller to the form's HTML/JavaScript section. Wrap initialization in a DOM-ready handler to prevent race conditions.
- Test Flows: Validate row addition, duplicate prevention, limit enforcement, and sequence recalculation. Use browser dev tools to inspect the hidden
<textarea> payload after each action.
- Deploy Server Guards: Replicate client validation rules in workflow validators or custom plugins. Test submission with disabled JavaScript to confirm server-side enforcement.