// file: crates/ksp-app-config-desk/frontend/ts/documents.ts // version: 2 //! Documents panel backed exclusively by ksp-config-lib inventory, validation and management APIs. import DataTable from "datatables.net-bs5"; import "datatables.net-select-bs5"; import "datatables.net-bs5/css/dataTables.bootstrap5.css"; import "datatables.net-select-bs5/css/select.bootstrap5.css"; import type { ConfigDocumentDetailDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto"; import type { ConfigDocumentErrorDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto"; import type { ConfigDocumentSaveResultDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto"; import type { ConfigDocumentSummaryDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto"; import { frontendDebug, frontendTrace, frontendWarn } from "./frontend_log"; import { invokeKsp } from "./invoke"; import { requestViewActivation, specializedEditorForFileId } from "./shell_registry"; interface DocumentsDataTable { destroy(): unknown; on(event: string, callback: (event: unknown, api: unknown, type: string, indexes: number[]) => void): unknown; row(selector: number): { node(): Node | null }; } let documentsTable: DocumentsDataTable | null = null; let activeDocument: ConfigDocumentDetailDto | null = null; const stageLabels: Record = { valid: "Valide", read: "Lecture", json: "JSON", schema: "Schema", semantic: "Sémantique", effective: "Effective", other: "Autre", }; function stageLabel(stage: string): string { return stageLabels[stage] ?? stage; } function setText(selector: string, value: string): void { const element = document.querySelector(selector); if (element) { element.textContent = value; } } function setDocumentsStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void { const status = document.querySelector("#documentsStatus"); if (!status) { return; } status.className = `alert alert-${tone} mb-0`; status.textContent = message; frontendTrace("main", "Documents panel status replaced", { tone }); } function diagnosticMessage(summary: ConfigDocumentSummaryDto): string { if (!summary.diagnostic) { return "Aucun diagnostic backend."; } return `${summary.diagnostic.domain}.${summary.diagnostic.code} — ${summary.diagnostic.message}`; } function renderDetail(detail: ConfigDocumentDetailDto): void { activeDocument = detail; setText("#documentDetailFileId", detail.summary.fileId); setText("#documentDetailFilename", detail.summary.filename); setText("#documentDetailSchema", detail.summary.schemaFileId ?? "—"); setText("#documentDetailPath", detail.summary.path); setText("#documentDetailStage", stageLabel(detail.summary.diagnosticStage)); setText("#documentDetailDiagnostic", diagnosticMessage(detail.summary)); const badge = document.querySelector("#documentDetailStatus"); if (badge) { const valid = detail.summary.validationStatus === "valid"; badge.className = `badge ${valid ? "text-bg-success" : "text-bg-danger"}`; badge.textContent = valid ? "Valide" : "Invalide"; } const editor = document.querySelector("#documentSourceEditor"); if (editor) { editor.value = detail.source ?? ""; editor.disabled = detail.source === null; } const saveButton = document.querySelector("#saveDocumentSource"); if (saveButton) { saveButton.disabled = detail.source === null; } const specializedEditor = specializedEditorForFileId(detail.summary.fileId); const specializedButton = document.querySelector("#openSpecializedEditor"); if (specializedButton) { specializedButton.hidden = specializedEditor === null; specializedButton.textContent = specializedEditor?.label ?? "Ouvrir l'éditeur spécialisé"; } const detailPanel = document.querySelector("#documentDetailPanel"); if (detailPanel) { detailPanel.hidden = false; } frontendTrace("main", "Config document detail rendered", { fileId: detail.summary.fileId, validationStatus: detail.summary.validationStatus, diagnosticStage: detail.summary.diagnosticStage, sourceAvailable: detail.source !== null, }); } function clearDetail(): void { activeDocument = null; const detailPanel = document.querySelector("#documentDetailPanel"); if (detailPanel) { detailPanel.hidden = true; } frontendTrace("main", "Config document detail cleared"); } async function loadDetail(fileId: string, source: "selection" | "reload"): Promise { frontendDebug("main", "Config document detail requested", { fileId, source }); try { const detail = await invokeKsp("main", "get_config_document_detail", { fileId }); renderDetail(detail); setDocumentsStatus(`Document ${fileId} chargé depuis Config.`, "success"); } catch (caughtError) { const error = asDocumentError(caughtError); const message = error ? `${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code} — ${error.error.message}` : "Le détail du document n'a pas pu être chargé."; setDocumentsStatus(message, "danger"); } } function populateTableRows(documents: ConfigDocumentSummaryDto[]): void { const body = document.querySelector("#configDocumentsTable tbody"); if (!body) { return; } body.replaceChildren(); for (const summary of documents) { const row = document.createElement("tr"); row.dataset.fileId = summary.fileId; appendCell(row, summary.fileId); appendCell(row, summary.filename); appendCell(row, summary.schemaFileId ?? "—"); appendCell(row, summary.validationStatus === "valid" ? "Valide" : "Invalide"); appendCell(row, stageLabel(summary.diagnosticStage)); body.append(row); } frontendTrace("main", "Config document inventory table DOM replaced", { documentCount: documents.length }); } function appendCell(row: HTMLTableRowElement, text: string): void { const cell = document.createElement("td"); cell.textContent = text; row.append(cell); } function initializeDataTable(): void { if (documentsTable) { documentsTable.destroy(); documentsTable = null; } const table = new DataTable("#configDocumentsTable", { order: [[0, "asc"]], pageLength: 10, select: { style: "single" }, language: { emptyTable: "Aucun document Config enregistré.", info: "_START_ à _END_ sur _TOTAL_ document(s)", infoEmpty: "0 document", lengthMenu: "Afficher _MENU_", search: "Filtrer :", zeroRecords: "Aucun document correspondant.", }, }) as unknown as DocumentsDataTable; table.on("select", (event, api, type, indexes) => { void event; void api; if (type !== "row" || indexes.length === 0) { return; } const node = table.row(indexes[0]).node(); if (!(node instanceof HTMLTableRowElement)) { return; } const fileId = node.dataset.fileId; frontendTrace("main", "Config document table row selected", { fileId: fileId ?? null }); if (fileId) { void loadDetail(fileId, "selection"); } }); documentsTable = table; frontendTrace("main", "DataTables Documents initialized", { selection: "single" }); } async function refreshInventory(): Promise { frontendDebug("main", "Config document inventory refresh requested"); clearDetail(); try { const documents = await invokeKsp("main", "get_config_documents"); populateTableRows(documents); initializeDataTable(); setDocumentsStatus(`${documents.length} document(s) Config évalué(s) par le backend.`, "success"); } catch { setDocumentsStatus("L'inventaire Config n'a pas pu être chargé.", "danger"); } } async function reloadActiveDocument(): Promise { if (!activeDocument) { return; } frontendDebug("main", "Config document reload clicked", { fileId: activeDocument.summary.fileId }); await loadDetail(activeDocument.summary.fileId, "reload"); } async function saveActiveDocument(): Promise { if (!activeDocument) { return; } const editor = document.querySelector("#documentSourceEditor"); if (!editor) { return; } const fileId = activeDocument.summary.fileId; frontendDebug("main", "Config document validated save clicked", { fileId }); setDocumentsStatus(`Validation et sauvegarde de ${fileId} en cours...`, "primary"); try { const result = await invokeKsp("main", "save_config_document_source", { fileId, source: editor.value, }); renderDetail(result.document); const outcome = result.sourceChanged ? "source modifiée" : "source inchangée"; setDocumentsStatus(`Sauvegarde validée par Config : ${outcome}; reload_required=${result.reloadRequired}.`, "success"); frontendDebug("main", "Config document validated save completed", { fileId, sourceChanged: result.sourceChanged, reloadRequired: result.reloadRequired, }); } catch (caughtError) { const error = asDocumentError(caughtError); if (error) { setDocumentsStatus( `${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code} — ${error.error.message}`, "danger", ); frontendWarn("main", "Config document candidate rejected by backend", { fileId, diagnosticStage: error.diagnosticStage }); return; } setDocumentsStatus("Le candidat Config a été rejeté sans diagnostic exploitable.", "danger"); } } function asDocumentError(value: unknown): ConfigDocumentErrorDto | null { if (typeof value !== "object" || value === null) { return null; } const candidate = value as Record; if (typeof candidate.diagnosticStage !== "string" || typeof candidate.error !== "object" || candidate.error === null) { return null; } const error = candidate.error as Record; if (typeof error.domain !== "string" || typeof error.code !== "string" || typeof error.message !== "string") { return null; } return value as ConfigDocumentErrorDto; } function bindDocumentActions(): void { document.querySelector("#refreshDocuments")?.addEventListener("click", () => { frontendTrace("main", "Documents refresh button clicked"); void refreshInventory(); }); document.querySelector("#reloadDocumentSource")?.addEventListener("click", () => { frontendTrace("main", "Document reload button clicked"); void reloadActiveDocument(); }); document.querySelector("#saveDocumentSource")?.addEventListener("click", () => { frontendTrace("main", "Document save button clicked"); void saveActiveDocument(); }); document.querySelector("#openSpecializedEditor")?.addEventListener("click", () => { const fileId = activeDocument?.summary.fileId ?? ""; const descriptor = specializedEditorForFileId(fileId); if (!descriptor) { return; } frontendDebug("main", "Specialized Config editor requested", { fileId, viewId: descriptor.viewId }); requestViewActivation(descriptor.viewId); }); frontendTrace("main", "Documents panel handlers installed"); } export function initializeDocumentsPanel(): void { bindDocumentActions(); void refreshInventory(); }