v0.1.4-pre.009
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
|
||||
<!-- version: 3 -->
|
||||
<!-- version: 4 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
@@ -55,6 +55,67 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="documentsPanel" hidden aria-label="Documents Config">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Documents enregistrés</h2>
|
||||
<p class="text-body-secondary small mb-0">Le registre, la validation et la persistence restent exclusivement sous contrôle de ksp-config-lib.</p>
|
||||
</div>
|
||||
<button id="refreshDocuments" class="btn btn-outline-primary btn-sm" type="button">
|
||||
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive mb-4">
|
||||
<table id="configDocumentsTable" class="table table-striped table-hover align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">file_id</th>
|
||||
<th scope="col">Fichier</th>
|
||||
<th scope="col">Schema</th>
|
||||
<th scope="col">État</th>
|
||||
<th scope="col">Étape</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section id="documentDetailPanel" hidden class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Détail du document Config">
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Détail du document</h2>
|
||||
<span id="documentDetailStatus" class="badge text-bg-secondary">—</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="reloadDocumentSource" class="btn btn-outline-secondary btn-sm" type="button">Recharger</button>
|
||||
<button id="saveDocumentSource" class="btn btn-primary btn-sm" type="button">Valider et sauvegarder</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="row small mb-3 app-document-metadata">
|
||||
<dt class="col-lg-3">file_id</dt>
|
||||
<dd id="documentDetailFileId" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Fichier mappé</dt>
|
||||
<dd id="documentDetailFilename" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Schema</dt>
|
||||
<dd id="documentDetailSchema" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Path résolu</dt>
|
||||
<dd id="documentDetailPath" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Étape diagnostic</dt>
|
||||
<dd id="documentDetailStage" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3 mb-0">Diagnostic</dt>
|
||||
<dd id="documentDetailDiagnostic" class="col-lg-9 mb-0">—</dd>
|
||||
</dl>
|
||||
<label class="form-label fw-semibold" for="documentSourceEditor">Source brut Config</label>
|
||||
<textarea id="documentSourceEditor" class="form-control app-document-source" rows="18" spellcheck="false" autocomplete="off"></textarea>
|
||||
<div class="form-text">La sauvegarde est refusée tant que le candidat échoue au parsing JSON, au schema ou aux invariants sémantiques backend.</div>
|
||||
</section>
|
||||
|
||||
<div id="documentsStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement de l'inventaire Config...</div>
|
||||
</section>
|
||||
|
||||
<section id="placeholderPanel" hidden aria-live="polite">
|
||||
<div class="alert alert-secondary mb-4" role="status">
|
||||
Cette route du shell est prête ; son contenu fonctionnel sera ajouté dans la prerelease prévue par le plan 0.1.4.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
@@ -52,7 +52,7 @@ body {
|
||||
}
|
||||
|
||||
.app-shell-card {
|
||||
max-width: 980px;
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.app-shell-status {
|
||||
@@ -72,3 +72,22 @@ body {
|
||||
.app-overview-list dd {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-document-metadata dd,
|
||||
.app-document-source,
|
||||
#configDocumentsTable td:first-child,
|
||||
#configDocumentsTable td:nth-child(2),
|
||||
#configDocumentsTable td:nth-child(3) {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-document-source {
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
#configDocumentsTable tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
279
crates/ksp-app-config-desk/frontend/ts/documents.ts
Normal file
279
crates/ksp-app-config-desk/frontend/ts/documents.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import "simplebar";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { AppSnapshotDto } from "./bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts";
|
||||
import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import { initializeDocumentsPanel } from "./documents";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
@@ -21,7 +22,7 @@ const viewCopy: Record<ViewId, { title: string; description: string }> = {
|
||||
},
|
||||
documents: {
|
||||
title: "Documents",
|
||||
description: "Inventaire, diagnostics et réparation des documents Config — surface fonctionnelle prévue en pre.009.",
|
||||
description: "Inventaire, diagnostics et réparation validée des documents Config enregistrés.",
|
||||
},
|
||||
profiles: {
|
||||
title: "Profils",
|
||||
@@ -69,9 +70,13 @@ function activateView(viewId: ViewId, source: "startup" | "user"): void {
|
||||
if (overview) {
|
||||
overview.hidden = viewId !== "overview";
|
||||
}
|
||||
const documents = document.querySelector<HTMLElement>("#documentsPanel");
|
||||
if (documents) {
|
||||
documents.hidden = viewId !== "documents";
|
||||
}
|
||||
const placeholder = document.querySelector<HTMLElement>("#placeholderPanel");
|
||||
if (placeholder) {
|
||||
placeholder.hidden = viewId === "overview";
|
||||
placeholder.hidden = viewId === "overview" || viewId === "documents";
|
||||
}
|
||||
frontendTrace("main", "Main view DOM updated", { viewId, source });
|
||||
}
|
||||
@@ -130,6 +135,7 @@ async function initializeMain(): Promise<void> {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
frontendInfo("main", "Config Desk main frontend loaded", { windowLabel });
|
||||
bindNavigation();
|
||||
initializeDocumentsPanel();
|
||||
activateView("overview", "startup");
|
||||
try {
|
||||
await loadSnapshot();
|
||||
|
||||
Reference in New Issue
Block a user