diff publishable/resources/js/ServerTable.js @ 4:84c75d9d90be

Changing usage to be bootstrap 5, not everything is reviewed but it's been started
author luka
date Tue, 19 Aug 2025 20:33:35 -0400
parents
children f282c6ef1671
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/publishable/resources/js/ServerTable.js	Tue Aug 19 20:33:35 2025 -0400
@@ -0,0 +1,247 @@
+class ServerTable {
+    /**
+     * @param {HTMLElement} rootEl - The container element for the table
+     * @param {Object} options - Configuration options
+     * @param {string} options.endpoint - API endpoint for data
+     * @param {Array} options.columns - Array of column configs: [{name, label, ...}]
+     * @param {number} [options.pageSize=10] - Default rows per page
+     * @param {Array} [options.initialSort=[]] - Default sort: [{col, dir}]
+     * @param {Object} [options.headers={}] - Additional headers for requests
+     * @param {string} [options.groupBy={}] - Which column to group by
+     * @param {Function} [options.groupRender={}] - Function to render the grouping
+     */
+    constructor(rootEl, options) {
+        this.rootEl = rootEl;
+        this.endpoint = options.endpoint;
+        this.columns = options.columns || [];
+        this.pageSize = options.pageSize || 10;
+        this.sort = options.initialSort || [];
+        this.filters = options.filters || {};
+        this.currentPage = 1;
+        this.headers = options.headers || {};
+        this.groupBy = options.groupBy;
+        this.groupRender =
+            options.groupRender ||
+            ((g, rows) =>
+                `<tr class="st-group-row"><td colspan="${this.columns.length}">Group: ${g}</td></tr>`);
+
+        this.skeleton = options.skeleton || `
+            <div class="st-table-container">
+                <table>
+                    <thead>
+                    </thead>
+                    <tbody>
+                        <!-- Data rows will go here -->
+                    </tbody>
+                </table>
+                <div class="st-controls">
+                    <span class="st-pagination"></span>
+                    <span class="st-status"></span>
+                </div>
+            </div>
+        `;
+
+        this.state = {
+            loading: false,
+            error: null,
+            totalRecords: 0,
+            records: [],
+        };
+
+        // Render initial skeleton and fetch initial data
+        this.renderSkeleton();
+        this.fetchData();
+    }
+
+    renderSkeleton() {
+        // Build base structure: table shell, controls area
+        this.rootEl.innerHTML = this.skeleton;
+        // Store references
+        this.head = this.rootEl.querySelector("thead");
+        this.tbody = this.rootEl.querySelector("tbody");
+        this.statusEl = this.rootEl.querySelector(".st-status");
+        this.paginationEl = this.rootEl.querySelector(".st-pagination");
+
+        // Draw the header
+        this.head.innerHTML = `
+                        <tr>
+                            ${this.columns.map((col) => `<th scope="col">${col.label || col.name}</th>`).join("")}
+                        </tr>
+                        `;
+    }
+
+    async fetchData() {
+        this.setLoading(true);
+        const payload = {
+            page: this.currentPage,
+            page_size: this.pageSize,
+            sort: this.sort,
+            filters: this.filters,
+        };
+
+        try {
+            const res = await fetch(this.endpoint, {
+                method: "POST",
+                headers: {
+                    ...this.headers,
+                    "Content-Type": "application/json",
+                },
+                body: JSON.stringify(payload),
+            });
+
+            if (!res.ok) {
+                throw new Error(`Server responded with ${res.status}`);
+            }
+
+            const data = await res.json();
+            // Minimal shape validation
+            if (
+                !Array.isArray(data.records) ||
+                typeof data.total_records !== "number"
+            ) {
+                throw new Error("Malformed server response");
+            }
+
+            // Save data in state
+            this.state.records = data.records;
+            this.state.totalRecords = data.total_records;
+            this.state.error = null;
+
+            this.renderRows();
+            this.updateControls();
+        } catch (err) {
+            this.state.error = err.message;
+            this.renderError();
+        } finally {
+            this.setLoading(false);
+        }
+    }
+
+    setLoading(loading) {
+        this.state.loading = loading;
+        this.statusEl.textContent = loading ? "Loading..." : "";
+    }
+
+    renderRows() {
+        const { records } = this.state;
+        const cols = this.columns;
+        const groupBy = this.groupBy;
+        const groupRender =
+            typeof this.groupRender === "function"
+                ? this.groupRender
+                : (g, rows) =>
+                      `<tr class="st-group-row"><td colspan="${cols.length}">Project: ${g}</td></tr>`;
+
+        if (!records || records.length === 0) {
+            this.tbody.innerHTML = `<tr><td colspan="${cols.length}">No data</td></tr>`;
+            return;
+        }
+
+        // Group if needed
+        if (groupBy) {
+            // Find grouping field or function
+            const getGroupValue =
+                typeof groupBy === "function" ? groupBy : (row) => row[groupBy];
+            let lastGroup = undefined;
+            let out = "";
+            let groupRows = [];
+
+            for (let i = 0; i < records.length; i++) {
+                const row = records[i];
+                const groupVal = getGroupValue(row);
+
+                // On group transition, flush previous group
+                if (i === 0 || groupVal !== lastGroup) {
+                    if (i > 0) {
+                        // Optionally do something with groupRows if groupRender wants it
+                    }
+                    // Insert group header row
+                    out += groupRender(groupVal, []);
+                    lastGroup = groupVal;
+                    groupRows = [];
+                }
+
+                groupRows.push(row);
+
+                out += `<tr>${cols
+                    .map((col, ci) => {
+                        // If grouping by this column, suppress repeated values (leave blank except for first row in group)
+                        if (
+                            typeof groupBy === "string" &&
+                            col.name === groupBy &&
+                            groupVal === lastGroup &&
+                            groupRows.length > 1
+                        ) {
+                            return `<td></td>`;
+                        }
+                        return `<td>${
+                            typeof col.render === "function"
+                                ? col.render(row, col, i)
+                                : row[col.name]
+                        }</td>`;
+                    })
+                    .join("")}</tr>`;
+            }
+            this.tbody.innerHTML = out;
+        } else {
+            // No grouping
+            this.tbody.innerHTML = records
+                .map(
+                    (row, i) =>
+                        `<tr>${cols
+                            .map(
+                                (col) =>
+                                    `<td>${
+                                        typeof col.render === "function"
+                                            ? col.render(row, col, i)
+                                            : row[col.name]
+                                    }</td>`,
+                            )
+                            .join("")}</tr>`,
+                )
+                .join("");
+        }
+    }
+    renderError() {
+        this.tbody.innerHTML = `<tr><td colspan="${this.columns.length}" style="color:red">${this.state.error}</td></tr>`;
+    }
+
+    updateControls() {
+        // Basic pagination info (full controls to come later)
+        const from = 1 + (this.currentPage - 1) * this.pageSize;
+        const to = Math.min(
+            this.currentPage * this.pageSize,
+            this.state.totalRecords,
+        );
+        this.paginationEl.textContent = `Showing ${from}-${to} of ${this.state.totalRecords}`;
+    }
+
+    // PUBLIC: force reload
+    reload() {
+        this.fetchData();
+    }
+
+    // PUBLIC: update filters, resets to page 1
+    setFilters(newFilters) {
+        this.filters = newFilters;
+        this.currentPage = 1;
+        this.fetchData();
+    }
+}
+
+// Example usage (not part of module export):
+/*
+const table = new ServerTable(document.getElementById('my-table'), {
+    endpoint: '/tickets/get_data',
+    columns: [
+        {name: 'id', label: 'ID'},
+        {name: 'subject', label: 'Subject'},
+        {name: 'project', label: 'Project'},
+        {name: 'created_at', label: 'Created At'},
+    ],
+    pageSize: 10,
+    initialSort: [{col: 'created_at', dir: 'desc'}]
+});
+*/
+
+window.ServerTable = ServerTable;