Files
khadhroony-solana-project/crates/ksp-app-config-desk/frontend/ts/documents.ts
2026-08-16 20:15:58 +02:00

296 lines
12 KiB
TypeScript

// 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<string, string> = {
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<HTMLElement>(selector);
if (element) {
element.textContent = value;
}
}
function setDocumentsStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
const status = document.querySelector<HTMLElement>("#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<HTMLElement>("#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<HTMLTextAreaElement>("#documentSourceEditor");
if (editor) {
editor.value = detail.source ?? "";
editor.disabled = detail.source === null;
}
const saveButton = document.querySelector<HTMLButtonElement>("#saveDocumentSource");
if (saveButton) {
saveButton.disabled = detail.source === null;
}
const specializedEditor = specializedEditorForFileId(detail.summary.fileId);
const specializedButton = document.querySelector<HTMLButtonElement>("#openSpecializedEditor");
if (specializedButton) {
specializedButton.hidden = specializedEditor === null;
specializedButton.textContent = specializedEditor?.label ?? "Ouvrir l'éditeur spécialisé";
}
const detailPanel = document.querySelector<HTMLElement>("#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<HTMLElement>("#documentDetailPanel");
if (detailPanel) {
detailPanel.hidden = true;
}
frontendTrace("main", "Config document detail cleared");
}
async function loadDetail(fileId: string, source: "selection" | "reload"): Promise<void> {
frontendDebug("main", "Config document detail requested", { fileId, source });
try {
const detail = await invokeKsp<ConfigDocumentDetailDto>("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<HTMLTableSectionElement>("#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<void> {
frontendDebug("main", "Config document inventory refresh requested");
clearDetail();
try {
const documents = await invokeKsp<ConfigDocumentSummaryDto[]>("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<void> {
if (!activeDocument) {
return;
}
frontendDebug("main", "Config document reload clicked", { fileId: activeDocument.summary.fileId });
await loadDetail(activeDocument.summary.fileId, "reload");
}
async function saveActiveDocument(): Promise<void> {
if (!activeDocument) {
return;
}
const editor = document.querySelector<HTMLTextAreaElement>("#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<ConfigDocumentSaveResultDto>("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<string, unknown>;
if (typeof candidate.diagnosticStage !== "string" || typeof candidate.error !== "object" || candidate.error === null) {
return null;
}
const error = candidate.error as Record<string, unknown>;
if (typeof error.domain !== "string" || typeof error.code !== "string" || typeof error.message !== "string") {
return null;
}
return value as ConfigDocumentErrorDto;
}
function bindDocumentActions(): void {
document.querySelector<HTMLButtonElement>("#refreshDocuments")?.addEventListener("click", () => {
frontendTrace("main", "Documents refresh button clicked");
void refreshInventory();
});
document.querySelector<HTMLButtonElement>("#reloadDocumentSource")?.addEventListener("click", () => {
frontendTrace("main", "Document reload button clicked");
void reloadActiveDocument();
});
document.querySelector<HTMLButtonElement>("#saveDocumentSource")?.addEventListener("click", () => {
frontendTrace("main", "Document save button clicked");
void saveActiveDocument();
});
document.querySelector<HTMLButtonElement>("#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();
}