280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
// file: crates/ksp-app-config-desk/frontend/ts/documents.ts
|
|
// version: 1
|
|
|
|
//! 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";
|
|
|
|
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 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();
|
|
});
|
|
frontendTrace("main", "Documents panel handlers installed");
|
|
}
|
|
|
|
export function initializeDocumentsPanel(): void {
|
|
bindDocumentActions();
|
|
void refreshInventory();
|
|
}
|