DEV Community

Explorer
Explorer

Posted on

Building a Native HTML Custom Data Grid in Joget Forms

Default Joget form grids work well for basic data entry, but sometimes you run into cases where you need total control over styling, custom column freezing, immediate cell validation, or client-side CSV downloads without pulling in heavy npm packages or external libraries.

In this guide, we'll build a lightweight HTML and jQuery custom grid that plugs directly into a Joget form. It handles frozen headers, length checks, line total calculations, and syncs automatically with a hidden Joget form field.


Why Use a Native HTML Grid?

  • Zero External Dependencies: Runs on standard HTML, CSS, and jQuery (which Joget already includes natively).
  • Fast Load Times: Doesn't slow down form rendering on mobile devices or low-bandwidth connections.
  • Full UI Customization: Easily freeze columns, format inputs, or style error states using simple CSS.

Setting Up the Grid Container

Add a Custom HTML element in Joget Form Builder and paste the following HTML container and styling:

<style>
.editableTableDiv {
    overflow-x: auto;
    max-height: 450px;
    border: 1px solid #d9d9d9;
}
#editableTable {
    width: 100%;
    border-collapse: collapse;
}
#editableTable th {
    background-color: #fafafa;
    padding: 8px;
    border: 1px solid #f0f0f0;
    font-size: 13px;
    text-align: left;
}
#editableTable td {
    padding: 0;
    border: 1px solid #f0f0f0;
}
#editableTable td input {
    width: 100%;
    box-sizing: border-box;
    border: none;
    padding: 6px;
    font-size: 12px;
}
#editableTable input.error {
    border: 1px solid #ff4d4f !important;
    background-color: #fff1f0 !important;
}
.frozen-col {
    position: sticky;
    left: 0;
    background-color: #ffffff;
    z-index: 2;
}
button.deleteRowBtn {
    background: transparent;
    border: none;
    color: #ff4d4f;
    cursor: pointer;
    padding: 4px 8px;
    font-weight: bold;
}
</style>

<div class="editableTableDiv">
    <table id="editableTable">
        <thead>
            <tr>
                <th class="frozen-col">Item Code</th>
                <th>Description</th>
                <th>Category</th>
                <th>Quantity</th>
                <th>Unit Price</th>
                <th>Total Amount</th>
                <th>Action</th>
            </tr>
        </thead>
        <tbody>
            <!-- Rows injected dynamically via JS -->
        </tbody>
    </table>
</div>

<div style="margin-top: 10px;">
    <button type="button" id="addRowBtn" class="form-button btn">Add Row</button>
    <button type="button" id="exportCsvBtn" class="form-button btn">Export CSV</button>
</div>
Enter fullscreen mode Exit fullscreen mode

JavaScript Logic & Joget Data Sync

Create a hidden Text Area field named list_grid_data in your form. The JavaScript below listens to user edits, updates line calculations, and serializes the full grid state into JSON so Joget stores it upon submission.

$(document).ready(function () {
    // Reference hidden storage element in Joget form
    const hiddenStorage = document.getElementById("list_grid_data");

    const rowTemplate = `
        <tr>
            <td class="frozen-col"><input type="text" class="item_code error" customminlength="3" maxlength="10" placeholder="Min 3 chars"></td>
            <td><input type="text" class="item_desc" maxlength="50"></td>
            <td><input type="text" class="item_category" maxlength="20"></td>
            <td><input type="number" class="item_qty" min="1" value="1"></td>
            <td><input type="number" class="item_price" step="0.01" value="0.00"></td>
            <td><input type="text" class="item_total" readonly style="background: #f5f5f5;"></td>
            <td style="text-align: center;"><button type="button" class="deleteRowBtn">✖</button></td>
        </tr>
    `;

    // Load existing stored data or start with 1 empty row
    function initGrid() {
        const existingData = hiddenStorage && hiddenStorage.value ? JSON.parse(hiddenStorage.value) : [];
        if (existingData.length > 0) {
            existingData.forEach(row => addRowWithValues(row));
        } else {
            $("#editableTable tbody").append(rowTemplate);
        }
    }

    function addRowWithValues(data) {
        const $row = $(rowTemplate);
        $row.find(".item_code").val(data.item_code || "");
        $row.find(".item_desc").val(data.description || "");
        $row.find(".item_category").val(data.category || "");
        $row.find(".item_qty").val(data.quantity || 1);
        $row.find(".item_price").val(data.unit_price || "0.00");
        $row.find(".item_total").val(data.total_amount || "0.00");
        $("#editableTable tbody").append($row);
    }

    $("#addRowBtn").on("click", function () {
        $("#editableTable tbody").append(rowTemplate);
        syncToJoget();
    });

    $(document).on("click", ".deleteRowBtn", function () {
        if ($("#editableTable tbody tr").length > 1) {
            $(this).closest("tr").remove();
            syncToJoget();
        }
    });

    // Real-time input validation & automatic line total calculation
    $(document).on("input", "#editableTable tbody input", function () {
        const $input = $(this);
        const minLen = parseInt($input.attr("customminlength"), 10);
        const val = $input.val();

        if (minLen && val.length < minLen) {
            $input.addClass("error");
        } else {
            $input.removeClass("error");
        }

        const $tr = $input.closest("tr");
        const qty = parseFloat($tr.find(".item_qty").val()) || 0;
        const price = parseFloat($tr.find(".item_price").val()) || 0;
        $tr.find(".item_total").val((qty * price).toFixed(2));

        syncToJoget();
    });

    // Save grid array to hidden Joget field
    function syncToJoget() {
        const gridData = [];
        $("#editableTable tbody tr").each(function () {
            const $tr = $(this);
            gridData.push({
                item_code: $tr.find(".item_code").val(),
                description: $tr.find(".item_desc").val(),
                category: $tr.find(".item_category").val(),
                quantity: $tr.find(".item_qty").val(),
                unit_price: $tr.find(".item_price").val(),
                total_amount: $tr.find(".item_total").val()
            });
        });
        if (hiddenStorage) {
            hiddenStorage.value = JSON.stringify(gridData);
        }
    }

    // Client-side CSV Download
    $("#exportCsvBtn").on("click", function () {
        let csv = "Item Code,Description,Category,Quantity,Unit Price,Total Amount\n";
        $("#editableTable tbody tr").each(function () {
            const $tr = $(this);
            const row = [
                $tr.find(".item_code").val(),
                `"${$tr.find(".item_desc").val()}"`,
                $tr.find(".item_category").val(),
                $tr.find(".item_qty").val(),
                $tr.find(".item_price").val(),
                $tr.find(".item_total").val()
            ].join(",");
            csv += row + "\n";
        });
        const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
        const link = document.createElement("a");
        link.href = URL.createObjectURL(blob);
        link.download = "Grid_Data_Export.csv";
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
    });

    initGrid();
});
</script>
Enter fullscreen mode Exit fullscreen mode

Best Practices & Security Note

  • Server-Side Validation: Always validate the grid JSON string in a BeanShell Form Validator plugin prior to saving to a database table.
  • Handling Empty Rows: Strip out empty or invalid rows before writing data in backend workflow scripts.

Top comments (0)