import { Controller } from "@hotwired/stimulus";
/**
* Expandable detail panel for approval rows in the Dataverse grid.
*
* Fetches rich context, progress, and response timeline from the
* approvalDetail API and renders an inline detail row beneath the
* clicked grid row.
*/
class ApprovalDetailController extends Controller {
static values = {
url: String,
triageUrl: String,
};
connect() {
this._expandedRows = new Map();
}
disconnect() {
this._expandedRows.forEach((detailRow) => detailRow.remove());
this._expandedRows.clear();
}
async toggle(event) {
event.preventDefault();
event.stopPropagation();
const btn = event.currentTarget;
const row = btn.closest("tr");
if (!row) return;
const approvalId = this._getApprovalId(row);
if (!approvalId) return;
if (this._expandedRows.has(approvalId)) {
this._collapse(approvalId, btn);
} else {
await this._expand(row, approvalId, btn);
}
}
// --- private helpers ---------------------------------------------------
_getApprovalId(row) {
const checkbox = row.querySelector("[data-row-id]");
if (checkbox) return checkbox.dataset.rowId;
const dataAttr = row.dataset.id;
if (dataAttr) return dataAttr;
const cells = row.querySelectorAll("td");
for (const cell of cells) {
const input = cell.querySelector("input[type=hidden][name*=id]");
if (input) return input.value;
}
const btn = row.querySelector("[data-outlet-btn-btn-data-value]");
if (btn) {
try {
const d = JSON.parse(
btn.getAttribute("data-outlet-btn-btn-data-value"),
);
if (d.id) return String(d.id);
} catch (_) {
/* ignore */
}
}
return null;
}
async _expand(row, approvalId, btn) {
btn.disabled = true;
const icon = btn.querySelector("i.bi");
if (icon) {
icon.classList.remove("bi-chevron-down");
icon.classList.add("bi-hourglass-split");
}
try {
const response = await fetch(this.urlValue + approvalId, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const colspan = row.querySelectorAll("td").length;
const detailRow = document.createElement("tr");
detailRow.classList.add("approval-detail-row");
detailRow.innerHTML = `<td colspan="${colspan}" class="p-0 border-top-0">${this._renderDetail(data)}</td>`;
row.after(detailRow);
this._expandedRows.set(approvalId, detailRow);
if (icon) {
icon.classList.remove("bi-hourglass-split");
icon.classList.add("bi-chevron-up");
}
} catch (err) {
console.error("Failed to load approval detail:", err);
if (icon) {
icon.classList.remove("bi-hourglass-split");
icon.classList.add("bi-chevron-down");
}
} finally {
btn.disabled = false;
}
}
_collapse(approvalId, btn) {
const detailRow = this._expandedRows.get(approvalId);
if (detailRow) detailRow.remove();
this._expandedRows.delete(approvalId);
const icon = btn.querySelector("i.bi");
if (icon) {
icon.classList.remove("bi-chevron-up");
icon.classList.add("bi-chevron-down");
}
}
_escapeHtml(str) {
if (str == null) return "";
const div = document.createElement("div");
div.appendChild(document.createTextNode(String(str)));
return div.innerHTML;
}
async updateTriage(event) {
event.preventDefault();
const form = event.currentTarget.closest("[data-approval-triage-form]");
if (!form || !this.hasTriageUrlValue) return;
const approvalId = form.dataset.approvalTriageForm;
const state =
form.querySelector("[data-approval-triage-state]")?.value || "new";
const note =
form.querySelector("[data-approval-triage-note]")?.value || "";
const status = form.querySelector("[data-approval-triage-status]");
const submit = event.currentTarget;
const csrfToken =
document.querySelector('meta[name="csrf-token"]')?.content || "";
submit.disabled = true;
if (status) {
status.textContent = "Saving triage state...";
}
try {
const body = new FormData();
body.append("approvalId", approvalId);
body.append("state", state);
body.append("note", note);
body.append("_csrfToken", csrfToken);
const response = await fetch(this.triageUrlValue, {
method: "POST",
headers: {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken,
},
body,
});
const result = await this._readJsonResponse(
response,
"Unable to save triage state. Please refresh the page and try again.",
);
if (!response.ok || result.success === false) {
throw new Error(result.error || `HTTP ${response.status}`);
}
if (status) {
status.textContent = "Private triage state saved.";
}
if (window.KMP_accessibility?.announce) {
window.KMP_accessibility.announce(
"Private triage state saved.",
);
}
} catch (error) {
if (status) {
status.textContent =
error.message || "Unable to save triage state.";
}
if (window.KMP_accessibility?.announce) {
window.KMP_accessibility.announce(
status?.textContent || "Unable to save triage state.",
"assertive",
);
}
} finally {
submit.disabled = false;
}
}
async _readJsonResponse(
response,
fallbackMessage = "Unexpected response from the server.",
) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch (error) {
throw new Error(fallbackMessage);
}
}
throw new Error(fallbackMessage);
}
_renderDetail(data) {
const h = (v) => this._escapeHtml(v);
const ctx = data.context || {};
const prog = data.progress || {};
const responses = data.responses || [];
const ui = data.ui || {};
const triage = data.triage || {};
const hideProgress =
ui.hideProgress === true || ui.feedbackResponse === true;
let html = '<div class="p-3 bg-light">';
html += '<div class="row">';
// ---- Left column: context ----
html += hideProgress
? '<div class="col-12">'
: '<div class="col-md-6 mb-3 mb-md-0">';
html += `<h6 class="mb-2"><i class="bi ${h(ctx.icon)} me-1"></i>${h(ctx.title)}</h6>`;
if (ctx.description) {
html += `<p class="text-muted small mb-2">${h(ctx.description)}</p>`;
}
if (ctx.fields && ctx.fields.length) {
ctx.fields.forEach((f) => {
html += `<div class="mb-1"><strong class="small text-body-secondary">${h(f.label)}:</strong> <span class="small">${h(f.value)}</span></div>`;
});
}
if (ctx.entityUrl) {
html += `<a href="${h(ctx.entityUrl)}" class="btn btn-sm btn-outline-primary mt-2" data-turbo-frame="_top"><i class="bi bi-box-arrow-up-right me-1" aria-hidden="true"></i>View Source</a>`;
}
if (ui.canTriage === true) {
html += this._renderTriageForm(data, triage);
}
html += "</div>";
if (!hideProgress) {
// ---- Right column: progress & timeline ----
html += '<div class="col-md-6">';
// Progress bar
html += '<h6 class="mb-2">Approval Progress</h6>';
const required = prog.required || 0;
const approved = prog.approved || 0;
const rejected = prog.rejected || 0;
const approvedPct =
required > 0 ? Math.round((approved / required) * 100) : 0;
const rejectedPct =
required > 0 ? Math.round((rejected / required) * 100) : 0;
html +=
'<div class="progress mb-1" role="progressbar" style="height:20px;">';
if (approvedPct > 0) {
html += `<div class="progress-bar bg-success" style="width:${approvedPct}%">${approved} approved</div>`;
}
if (rejectedPct > 0) {
html += `<div class="progress-bar bg-danger" style="width:${rejectedPct}%">${rejected} rejected</div>`;
}
html += "</div>";
html += `<p class="text-muted small mb-3">${approved} of ${required} required · Status: <strong>${h(prog.statusLabel || prog.status)}</strong></p>`;
if (prog.statusExplanation) {
html +=
'<div class="alert alert-info py-2 small" role="status" aria-live="polite" aria-atomic="true">';
html +=
'<div class="fw-semibold mb-1"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Why this approval was reset</div>';
html += `<div>${h(prog.statusExplanation)}</div>`;
html += "</div>";
}
// Response timeline
if (responses.length > 0) {
html += '<h6 class="mb-2">Response Timeline</h6>';
html += '<ul class="list-group list-group-flush small">';
responses.forEach((r) => {
const isApprove = r.decision === "approve";
const iconCls = isApprove
? "bi-check-circle-fill text-success"
: "bi-x-circle-fill text-danger";
html +=
'<li class="list-group-item bg-transparent px-0 py-1">';
html += `<i class="bi ${iconCls} me-1"></i>`;
html += `<strong>${h(r.memberName)}</strong> — ${h(r.decision)}`;
if (r.comment) {
html += ` <em class="text-muted">“${h(r.comment)}”</em>`;
}
html += ` <span class="text-muted">(${h(r.respondedAt)})</span>`;
html += "</li>";
});
html += "</ul>";
} else {
html +=
'<p class="text-muted small fst-italic mb-0">No responses yet.</p>';
}
html += "</div>";
}
html += "</div></div>";
return html;
}
_renderTriageForm(data, triage) {
const h = (v) => this._escapeHtml(v);
const approvalId = data.progress?.approvalId || data.approvalId || "";
const selectId = `approval-triage-state-${h(approvalId)}`;
const noteId = `approval-triage-note-${h(approvalId)}`;
const helpId = `approval-triage-help-${h(approvalId)}`;
const statusId = `approval-triage-status-${h(approvalId)}`;
const states = triage.states || {};
let options = "";
Object.keys(states).forEach((state) => {
options += `<option value="${h(state)}"${state === triage.state ? " selected" : ""}>${h(states[state])}</option>`;
});
return `<form class="border rounded bg-white p-2 mt-3"
data-approval-triage-form="${h(approvalId)}">
<div class="mb-2">
<label class="form-label small fw-semibold" for="${selectId}">Private triage state</label>
<select class="form-select form-select-sm" id="${selectId}" data-approval-triage-state aria-describedby="${helpId}">
${options}
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold" for="${noteId}">Private note</label>
<textarea class="form-control form-control-sm" id="${noteId}" data-approval-triage-note rows="2" aria-describedby="${helpId}">${h(triage.note || "")}</textarea>
<div class="form-text" id="${helpId}">Only you can see this triage note. It does not submit an approval decision.</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" data-action="click->approval-detail#updateTriage">
Save private triage
</button>
<span class="small text-muted" id="${statusId}" data-approval-triage-status role="status" aria-live="polite"></span>
</div>
</form>`;
}
}
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["approval-detail"] = ApprovalDetailController;