feat(submissions): t-paliad-370 S3 — Vorschau base-preview modal scaffold

docforge UX slice S3 (PRD §2.1 / §3 S3). A shared, body-attached modal lets
the lawyer browse template bases, flip output language + data-mode, and — in
the editor — commit a base with 'Diese Basis verwenden', replacing the blind
<select> as the chooser. Opened from the editor 👁 button AND both catalog row
kebabs (the S1/S2 stubs are now live).

Body renders the EXISTING structural preview on cheap rails; the body region is
swappable so S4 drops a truthful .docx→image render behind the same modal +
endpoint shape. Spinner + pager are the shell S4 expects.

Backend (minimal, no truthful render — that's S4):
- GET /api/submission-preview?base&code&lang&data&draft?&project? → {preview_html}.
  Draft+mine uses the draft's resolved bag (previews a base the draft has NOT
  committed to — no persistence); else a fresh context bag (project-less fill,
  t-paliad-364). data=sample swaps a sample missing-marker so unresolved keys
  render readable sample text, not [KEIN WERT].
- SubmissionDraftService.RenderPreviewWithMarker + RenderContextPreviewHTML
  (thin: reuse BuildRenderBag / vars.Build + RenderHTML).
- resolvePreviewTemplateBytes: tpl:→carrier, base_id→bytes IFF it carries merge
  placeholders, else fall back to the code's merge template (anchors-only
  Composer bases can't be shown as structural HTML — their letterhead/styling
  surfaces in S4's truthful render). sampleMissingMarker by key suffix.
- Unit tests: sampleMissingMarker + normalizePreviewLang.

Frontend:
- New shared client/base-preview-modal.ts (built once, body-attached; serves
  editor + project tab + global picker). Base-switcher, DE/EN + meine/Beispiel
  toggles, swappable body, spinner, pager stub, conditional 'Diese Basis
  verwenden' (editor commits via onBaseChange; catalog = look-only).
- Editor 👁 button un-stubbed + wired; both catalog kebab 'Vorschau' items
  un-stubbed + wired. global.css: .base-preview-* modal styles.

bun build (i18n scan clean) + go vet ./... + go test ./... (15 ok, 0 fail).
This commit is contained in:
mAi
2026-06-01 18:42:08 +02:00
parent 7ec1908383
commit a70508a7d5
10 changed files with 742 additions and 14 deletions

View File

@@ -0,0 +1,278 @@
// Shared base-preview modal (t-paliad-370 S3, PRD §2.1).
//
// Opened from the editor's 👁 Vorschau button and from the catalog row
// kebabs ("Vorschau Vorlagenbasis"). Lets the lawyer browse template bases,
// flip the output language + data-mode, and — in the editor — commit a base
// with "Diese Basis verwenden", replacing the blind <select> as the chooser.
//
// S3 renders the EXISTING structural preview HTML (GET /api/submission-preview)
// in a swappable body region. S4 swaps that region for a truthful .docx→image
// render behind the same modal + endpoint shape; the pager + spinner are the
// shell that work already expects.
//
// Built once and body-attached so a single modal serves all three host pages
// (editor, project Schriftsätze tab, global picker) without triplicated SSR.
interface BaseRow { id: string; label_de: string; label_en: string }
interface TemplateRow { version_id?: string; name_de: string; name_en: string }
export interface BasePreviewOpts {
/** submission_code — drives caption + the fallback template. */
code: string;
/** Initial output language. */
lang: string;
/** Editor context: preview against this draft's resolved data. */
draftId?: string;
/** Catalog (project tab) context: scope sample/own data to this project. */
projectId?: string | null;
/** Current selection (base_id | "tpl:"+version_id | ""), preselected. */
currentBase?: string;
/** Default data-mode; falls back to "mine" with a draft, else "sample". */
defaultData?: "mine" | "sample";
/** Editor: commit the chosen base. Omitted ⇒ catalog (preview-only). */
onApply?: (baseValue: string) => void;
}
interface ModalState {
opts: BasePreviewOpts;
selectedBase: string;
lang: string;
data: "mine" | "sample";
}
let modal: HTMLElement | null = null;
let bodyRegion: HTMLElement | null = null;
let baseSelect: HTMLSelectElement | null = null;
let applyBtn: HTMLButtonElement | null = null;
let pager: HTMLElement | null = null;
let state: ModalState | null = null;
// Base/template catalog, fetched once and reused across opens.
let bases: BaseRow[] = [];
let templates: TemplateRow[] = [];
let catalogLoaded = false;
let reqSeq = 0; // guards against out-of-order preview responses
function isEN(): boolean {
return document.documentElement.lang === "en";
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function ensureBuilt(): void {
if (modal) return;
modal = document.createElement("div");
modal.className = "base-preview-overlay";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.style.display = "none";
const card = document.createElement("div");
card.className = "base-preview-card";
modal.appendChild(card);
// Header: base switcher + language toggle + data toggle + close.
const header = document.createElement("header");
header.className = "base-preview-header";
header.innerHTML = `
<div class="base-preview-controls">
<label class="base-preview-field">
<span>${esc(isEN() ? "Template base" : "Vorlagenbasis")}</span>
<select class="base-preview-base"></select>
</label>
<div class="base-preview-toggle base-preview-lang" role="group" aria-label="${esc(isEN() ? "Language" : "Sprache")}">
<button type="button" data-lang="de">DE</button>
<button type="button" data-lang="en">EN</button>
</div>
<div class="base-preview-toggle base-preview-data" role="group" aria-label="${esc(isEN() ? "Data" : "Daten")}">
<button type="button" data-data="mine">${esc(isEN() ? "My data" : "Meine Daten")}</button>
<button type="button" data-data="sample">${esc(isEN() ? "Sample" : "Beispiel")}</button>
</div>
</div>
<button type="button" class="base-preview-close" aria-label="${esc(isEN() ? "Close" : "Schließen")}">&times;</button>
`;
card.appendChild(header);
// Swappable body region — S3 structural HTML, S4 truthful page image.
bodyRegion = document.createElement("div");
bodyRegion.className = "base-preview-body";
card.appendChild(bodyRegion);
// Footer: pager (S4) + apply.
const footer = document.createElement("footer");
footer.className = "base-preview-footer";
pager = document.createElement("div");
pager.className = "base-preview-pager";
footer.appendChild(pager);
applyBtn = document.createElement("button");
applyBtn.type = "button";
applyBtn.className = "btn-primary btn-cta-lime btn-small base-preview-apply";
applyBtn.textContent = isEN() ? "Use this base" : "Diese Basis verwenden";
footer.appendChild(applyBtn);
card.appendChild(footer);
document.body.appendChild(modal);
baseSelect = header.querySelector<HTMLSelectElement>(".base-preview-base");
// Wiring.
baseSelect?.addEventListener("change", () => {
if (!state || !baseSelect) return;
state.selectedBase = baseSelect.value;
renderPreview();
});
header.querySelectorAll<HTMLButtonElement>(".base-preview-lang button").forEach((b) => {
b.addEventListener("click", () => {
if (!state) return;
state.lang = b.dataset.lang === "en" ? "en" : "de";
paintToggles();
renderPreview();
});
});
header.querySelectorAll<HTMLButtonElement>(".base-preview-data button").forEach((b) => {
b.addEventListener("click", () => {
if (!state) return;
state.data = b.dataset.data === "sample" ? "sample" : "mine";
paintToggles();
renderPreview();
});
});
header.querySelector<HTMLButtonElement>(".base-preview-close")?.addEventListener("click", close);
applyBtn.addEventListener("click", () => {
if (!state || !state.opts.onApply) return;
state.opts.onApply(state.selectedBase);
close();
});
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal && modal.style.display !== "none") close();
});
}
function paintToggles(): void {
if (!modal || !state) return;
modal.querySelectorAll<HTMLButtonElement>(".base-preview-lang button").forEach((b) => {
b.classList.toggle("is-active", b.dataset.lang === state!.lang);
});
modal.querySelectorAll<HTMLButtonElement>(".base-preview-data button").forEach((b) => {
b.classList.toggle("is-active", b.dataset.data === state!.data);
});
}
async function loadCatalog(): Promise<void> {
if (catalogLoaded) return;
try {
const [bRes, tRes] = await Promise.all([
fetch("/api/submission-bases", { credentials: "include" }),
fetch("/api/templates", { credentials: "include" }),
]);
if (bRes.ok) bases = ((await bRes.json()) as { bases?: BaseRow[] }).bases ?? [];
if (tRes.ok) {
templates = (((await tRes.json()) as { templates?: TemplateRow[] }).templates ?? [])
.filter((t) => !!t.version_id);
}
} catch {
/* leave catalog empty — the modal still previews the current/fallback base */
}
catalogLoaded = true;
}
function paintBaseSelect(): void {
if (!baseSelect || !state) return;
baseSelect.innerHTML = "";
const fallback = document.createElement("option");
fallback.value = "";
fallback.textContent = isEN() ? "— Standard template —" : "— Standardvorlage —";
baseSelect.appendChild(fallback);
for (const b of bases) {
const opt = document.createElement("option");
opt.value = b.id;
opt.textContent = isEN() ? b.label_en : b.label_de;
baseSelect.appendChild(opt);
}
if (templates.length > 0) {
const group = document.createElement("optgroup");
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
for (const t of templates) {
const opt = document.createElement("option");
opt.value = "tpl:" + t.version_id;
opt.textContent = isEN() ? t.name_en : t.name_de;
group.appendChild(opt);
}
baseSelect.appendChild(group);
}
baseSelect.value = state.selectedBase;
}
async function renderPreview(): Promise<void> {
if (!bodyRegion || !state) return;
const seq = ++reqSeq;
bodyRegion.innerHTML = `<div class="base-preview-spinner">${esc(isEN() ? "Rendering…" : "Wird gerendert…")}</div>`;
if (pager) pager.textContent = "";
const params = new URLSearchParams({
base: state.selectedBase,
code: state.opts.code,
lang: state.lang,
data: state.data,
});
if (state.opts.draftId) {
params.set("draft", state.opts.draftId);
} else if (state.opts.projectId) {
params.set("project", state.opts.projectId);
}
try {
const res = await fetch(`/api/submission-preview?${params.toString()}`, { credentials: "include" });
if (seq !== reqSeq) return; // a newer request superseded this one
if (!res.ok) {
bodyRegion.innerHTML = `<div class="base-preview-error">${esc(isEN() ? "Preview unavailable." : "Vorschau nicht verfügbar.")}</div>`;
return;
}
const data = (await res.json()) as { preview_html?: string };
if (seq !== reqSeq) return;
bodyRegion.innerHTML = `<div class="base-preview-page">${data.preview_html ?? ""}</div>`;
// S3 renders one flowing HTML block; the pager is the S4 page-image shell.
if (pager) pager.textContent = isEN() ? "Structural preview" : "Struktur-Vorschau";
} catch {
if (seq !== reqSeq) return;
bodyRegion.innerHTML = `<div class="base-preview-error">${esc(isEN() ? "Preview failed." : "Vorschau fehlgeschlagen.")}</div>`;
}
}
function close(): void {
if (modal) modal.style.display = "none";
state = null;
}
/** Open the base-preview modal. See BasePreviewOpts. */
export async function openBasePreview(opts: BasePreviewOpts): Promise<void> {
ensureBuilt();
if (!modal || !applyBtn) return;
state = {
opts,
selectedBase: opts.currentBase ?? "",
lang: opts.lang === "en" ? "en" : "de",
data: opts.defaultData ?? (opts.draftId ? "mine" : "sample"),
};
// Apply button only when the caller can commit (editor); catalog = look-only.
applyBtn.style.display = opts.onApply ? "" : "none";
modal.style.display = "";
paintToggles();
await loadCatalog();
if (!state) return; // closed while loading
paintBaseSelect();
void renderPreview();
}

View File

@@ -1,5 +1,6 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { openBasePreview } from "./base-preview-modal";
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
@@ -669,6 +670,29 @@ function paintNameRow(): void {
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
// t-paliad-370 S3 — 👁 Vorschau opens the base-preview modal for the
// current draft (its data), with the base-switcher as the chooser; picking
// "Diese Basis verwenden" commits via the existing base-swap path.
const previewBaseBtn = document.getElementById("submission-draft-preview-base-btn") as HTMLButtonElement | null;
if (previewBaseBtn) {
previewBaseBtn.onclick = () => {
if (!state.view) return;
const d = state.view.draft;
const current = d.template_version_id
? "tpl:" + d.template_version_id
: (d.base_id ?? "");
void openBasePreview({
code: d.submission_code,
lang: d.language || state.view.lang || "de",
draftId: d.id,
projectId: d.project_id,
currentBase: current,
defaultData: "mine",
onApply: (value) => { void onBaseChange(value); },
});
};
}
}
// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.

View File

@@ -1,6 +1,7 @@
import { initI18n, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { createRowActionMenu } from "./row-action-menu";
import { openBasePreview } from "./base-preview-modal";
// t-paliad-243 — client for /submissions/new. Fetches the
// cross-proceeding submission catalog, groups it by proceeding, filters
@@ -197,12 +198,14 @@ function renderTable(): void {
onSelect: () => { if (code) openProjectPicker(code); },
},
{
// S3 wires this to the truthful base-preview modal (PRD §2).
// Stubbed disabled until then so the kebab structure ships in S1.
// t-paliad-370 S3 — eyeball the base before starting a draft.
// Look-only (no draft, no project): sample data fills the page.
label: isEN() ? "Preview template base" : "Vorschau Vorlagenbasis",
disabled: true,
title: isEN() ? "Coming soon" : "Bald verfügbar",
onSelect: () => {},
onSelect: () => void openBasePreview({
code,
lang: isEN() ? "en" : "de",
defaultData: "sample",
}),
},
],
{ ariaLabel: isEN() ? "More actions" : "Weitere Aktionen" },

View File

@@ -14,6 +14,7 @@
// preview), consistent with the global picker.
import { createRowActionMenu } from "./row-action-menu";
import { openBasePreview } from "./base-preview-modal";
function escapeHtml(s: string): string {
return s
@@ -163,12 +164,16 @@ function render(data: SubmissionListResponse): void {
onSelect: () => void generateAndDownload(code, projectID),
},
{
// S3 wires this to the truthful base-preview modal (PRD §2).
// Stubbed disabled until then so the kebab structure ships in S1.
// t-paliad-370 S3 — eyeball the base before opening the editor.
// Look-only here (no draft yet): no onApply, scoped to the project
// so the preview can use its data. Sample data fills the gaps.
label: isEN ? "Preview template base" : "Vorschau Vorlagenbasis",
disabled: true,
title: isEN ? "Coming soon" : "Bald verfügbar",
onSelect: () => {},
onSelect: () => void openBasePreview({
code,
lang: isEN ? "en" : "de",
projectId: projectID,
defaultData: "sample",
}),
},
],
{ ariaLabel: isEN ? "More actions" : "Weitere Aktionen" },

View File

@@ -6288,6 +6288,149 @@ dialog.modal::backdrop {
cursor: default;
}
/* t-paliad-370 S3 — base-preview modal (PRD §2.1). Body-attached, shared by
the editor + both catalog surfaces. The body region is swappable: S3 shows
structural HTML; S4 swaps in a truthful .docx page image. */
.base-preview-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay-modal);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 1rem;
}
.base-preview-card {
background: var(--color-surface);
border-radius: calc(var(--radius) * 1.5);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 820px;
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.base-preview-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.base-preview-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
}
.base-preview-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 220px;
}
.base-preview-field > span {
font-size: 0.8em;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.base-preview-field select {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-elev-1);
font-size: 0.95em;
}
.base-preview-toggle {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 6px;
overflow: hidden;
}
.base-preview-toggle button {
padding: 0.4rem 0.7rem;
border: none;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: 0.85em;
cursor: pointer;
}
.base-preview-toggle button + button {
border-left: 1px solid var(--color-border);
}
.base-preview-toggle button.is-active {
background: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 600;
}
.base-preview-close {
flex: 0 0 auto;
border: none;
background: transparent;
font-size: 1.5rem;
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
padding: 0 0.25rem;
}
.base-preview-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1.5rem;
background: var(--color-surface-muted);
}
/* The rendered page sits on a paper-like sheet so the structural preview
reads as a document, prefiguring the S4 page image. */
.base-preview-page {
background: #fff;
color: #111;
max-width: 680px;
margin: 0 auto;
padding: 2.5rem 3rem;
border-radius: 2px;
box-shadow: var(--shadow-md);
font-size: 0.9rem;
line-height: 1.5;
}
.base-preview-spinner,
.base-preview-error {
text-align: center;
color: var(--color-text-muted);
padding: 2rem;
}
.base-preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1.25rem;
border-top: 1px solid var(--color-border);
}
.base-preview-pager {
font-size: 0.8em;
color: var(--color-text-muted);
}
@media (max-width: 900px) {
.submission-draft-toolbar > .submission-draft-switcher,
.submission-draft-toolbar > .submission-draft-name-row,

View File

@@ -157,10 +157,9 @@ export function renderSubmissionDraft(): string {
type="button"
id="submission-draft-preview-base-btn"
className="btn-icon submission-draft-preview-base-btn"
disabled
aria-label="Vorschau Vorlagenbasis"
data-i18n-title="submissions.draft.base.preview.soon"
title="Bald verf&uuml;gbar">
data-i18n-title="submissions.draft.base.preview"
title="Vorschau Vorlagenbasis">
&#128065;
</button>
</div>

View File

@@ -478,6 +478,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// the sidebar picker. Wide-open SELECT (any authenticated user);
// admin mutations are not exposed yet (Slice C).
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
// t-paliad-370 S3 — structural HTML preview of a base/template for the
// base-preview modal (draft-optional; S4 returns a truthful page image
// behind the same query shape).
protected.HandleFunc("GET /api/submission-preview", handleSubmissionPreview)
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)

View File

@@ -560,6 +560,191 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
}
// handleSubmissionPreview renders a STRUCTURAL HTML preview of a chosen base
// (or uploaded template) for the base-preview modal (t-paliad-370 S3). It is
// the cheap-rails counterpart of the truthful .docx→image render added in S4 —
// same query shape, so S4 can return a real page image behind the same modal.
//
// GET /api/submission-preview
// ?base=<base_id uuid | "tpl:"+version_id | ""> which template to render
// &code=<submission_code> caption + fallback template
// &lang=de|en
// &data=mine|sample missing-value rendering
// &draft=<draft uuid> optional: use the draft's bag
// &project=<project uuid> optional (no draft): project data
//
// No persistence. With a draft + data=mine the draft's resolved bag is used, so
// the modal previews a base the draft has NOT committed to. Otherwise a fresh
// context bag is built for (user, project?, code, lang). data=sample swaps the
// missing-marker so unresolved placeholders render readable sample text instead
// of [KEIN WERT] (a fresh/project-less draft then previews a full page).
func handleSubmissionPreview(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
return
}
q := r.URL.Query()
baseRef := strings.TrimSpace(q.Get("base"))
code := strings.TrimSpace(q.Get("code"))
lang := normalizePreviewLang(q.Get("lang"))
sample := strings.EqualFold(strings.TrimSpace(q.Get("data")), "sample")
ctx, cancel := context.WithTimeout(r.Context(), submissionDraftPreviewTimeout)
defer cancel()
// Editor context: load the draft so we can use its resolved bag and
// default code/lang from it.
var draft *services.SubmissionDraft
if draftRef := strings.TrimSpace(q.Get("draft")); draftRef != "" {
id, perr := uuid.Parse(draftRef)
if perr != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid draft id"})
return
}
d, derr := dbSvc.submissionDraft.Get(ctx, uid, id)
if derr != nil {
writeSubmissionDraftServiceError(w, derr)
return
}
draft = d
if code == "" {
code = draft.SubmissionCode
}
if q.Get("lang") == "" {
lang = normalizePreviewLang(draft.Language)
}
}
if code == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code required"})
return
}
tplBytes, err := resolvePreviewTemplateBytes(ctx, baseRef, code, lang)
if err != nil {
log.Printf("submission_preview: template resolve (base=%q code=%q): %v", baseRef, code, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template unavailable"})
return
}
var missing docforge.MissingPlaceholderFn
if sample {
missing = sampleMissingMarker(lang)
}
if draft != nil {
html, rerr := dbSvc.submissionDraft.RenderPreviewWithMarker(ctx, draft, tplBytes, missing)
if rerr != nil {
log.Printf("submission_preview: render draft=%s: %v", draft.ID, rerr)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
return
}
// Catalog context (no draft): optional project scopes the data.
var projectID *uuid.UUID
if projectRef := strings.TrimSpace(q.Get("project")); projectRef != "" {
if pid, perr := uuid.Parse(projectRef); perr == nil {
projectID = &pid
}
}
html, rerr := dbSvc.submissionDraft.RenderContextPreviewHTML(ctx, uid, projectID, code, lang, tplBytes, missing)
if rerr != nil {
log.Printf("submission_preview: render context (code=%q project=%v): %v", code, projectID, rerr)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
}
// normalizePreviewLang clamps the preview lang query to "de" (default) or "en".
func normalizePreviewLang(s string) string {
if strings.EqualFold(strings.TrimSpace(s), "en") {
return "en"
}
return "de"
}
// resolvePreviewTemplateBytes resolves the template bytes the base-preview modal
// should render for a base reference. Uploaded templates ("tpl:<v>") render
// their carrier directly; a Gitea base_id renders its bytes only if they carry
// {{merge placeholders}} — anchors-only Composer bases (the styled HL skeletons)
// can't be shown as structural HTML, so they fall back to the submission_code's
// merge template (matching today's preview-pane semantics). The per-base
// letterhead/styling becomes visible in S4's truthful .docx render.
func resolvePreviewTemplateBytes(ctx context.Context, baseRef, code, lang string) ([]byte, error) {
switch {
case strings.HasPrefix(baseRef, "tpl:"):
if dbSvc.templateStore != nil {
tmpl, err := dbSvc.templateStore.GetVersion(ctx, strings.TrimPrefix(baseRef, "tpl:"))
switch {
case err == nil:
return tmpl.CarrierBytes, nil
case !errors.Is(err, docforge.ErrTemplateNotFound):
return nil, err
}
// missing pinned version → fall through to the code template
}
case baseRef != "":
if dbSvc.submissionBase != nil {
if id, perr := uuid.Parse(baseRef); perr == nil {
if base, berr := dbSvc.submissionBase.GetByID(ctx, id); berr == nil {
if b, _, ferr := fetchComposerBaseBytes(ctx, base); ferr == nil && docx.HasMergePlaceholders(b) {
return b, nil
}
}
}
}
}
b, _, _, err := resolveSubmissionTemplate(ctx, code, lang)
return b, err
}
// sampleMissingMarker renders unresolved placeholders as readable sample text
// (the modal's "Beispiel" data-mode) instead of [KEIN WERT], so a fresh or
// project-less draft previews a full page. Matching is by key suffix/substring
// so it is robust to the exact namespace; unknown keys get a generic sample
// token. Cosmetic only — S4's truthful render fills sample data end-to-end.
func sampleMissingMarker(lang string) docforge.MissingPlaceholderFn {
en := strings.EqualFold(lang, "en")
return func(key string) string {
k := strings.ToLower(key)
switch {
case strings.Contains(k, "case_number"):
return "4c O 12/23"
case strings.Contains(k, "claimant") && strings.Contains(k, "name"):
if en {
return "Sample Claimant Ltd."
}
return "Mustermandant GmbH"
case strings.Contains(k, "defendant") && strings.Contains(k, "name"):
if en {
return "Sample Defendant Inc."
}
return "Musterbeklagte AG"
case strings.Contains(k, "court"):
if en {
return "Düsseldorf Regional Court"
}
return "Landgericht Düsseldorf"
default:
if en {
return "Sample"
}
return "Beispiel"
}
}
}
// handleExportSubmissionDraft merges the draft into the .docx template
// and streams the result. Writes one system_audit_log row and one
// project_events row per successful export.

View File

@@ -0,0 +1,50 @@
package handlers
import "testing"
// t-paliad-370 S3 — the base-preview modal's "Beispiel" data-mode renders
// unresolved placeholders as readable sample text instead of [KEIN WERT].
// Matching is by key suffix/substring so it survives namespace changes;
// unknown keys fall back to a generic token.
func TestSampleMissingMarker(t *testing.T) {
de := sampleMissingMarker("de")
en := sampleMissingMarker("en")
cases := []struct {
key string
wantDE string
wantEN string
}{
{"project.case_number", "4c O 12/23", "4c O 12/23"},
{"parties.claimant.0.name", "Mustermandant GmbH", "Sample Claimant Ltd."},
{"parties.defendant.0.name", "Musterbeklagte AG", "Sample Defendant Inc."},
{"project.court", "Landgericht Düsseldorf", "Düsseldorf Regional Court"},
{"some.unknown.key", "Beispiel", "Sample"},
}
for _, c := range cases {
if got := de(c.key); got != c.wantDE {
t.Errorf("de marker for %q = %q, want %q", c.key, got, c.wantDE)
}
if got := en(c.key); got != c.wantEN {
t.Errorf("en marker for %q = %q, want %q", c.key, got, c.wantEN)
}
}
// A sample marker must never emit the [KEIN WERT] / [NO VALUE] form —
// that's the whole point of the "Beispiel" mode.
if got := de("anything.at.all"); got == "" {
t.Error("de marker returned empty string")
}
}
func TestNormalizePreviewLang(t *testing.T) {
cases := map[string]string{
"de": "de", "DE": "de", "": "de", " fr ": "de", "garbage": "de",
"en": "en", "EN": "en", " en ": "en",
}
for in, want := range cases {
if got := normalizePreviewLang(in); got != want {
t.Errorf("normalizePreviewLang(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -1013,11 +1013,48 @@ func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *Subm
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
// with <strong>/<em> spans for runs flagged bold/italic.
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
return s.RenderPreviewWithMarker(ctx, draft, templateBytes, nil)
}
// RenderPreviewWithMarker is RenderPreview with a pluggable missing-value
// marker. The base-preview modal (t-paliad-370 S3) passes a sample marker for
// its "Beispiel" data-mode so a draft's unresolved placeholders render readable
// sample text instead of [KEIN WERT]. nil marker == the default DE/EN marker.
// It renders arbitrary templateBytes against the draft's resolved bag WITHOUT
// persisting, so the modal can preview a base the draft hasn't committed to.
func (s *SubmissionDraftService) RenderPreviewWithMarker(ctx context.Context, draft *SubmissionDraft, templateBytes []byte, missing MissingPlaceholderFn) (string, error) {
bag, resolved, err := s.BuildRenderBag(ctx, draft)
if err != nil {
return "", err
}
return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
if missing == nil {
missing = DefaultMissingMarker(resolved.Lang)
}
return s.renderer.RenderHTML(templateBytes, bag, missing)
}
// RenderContextPreviewHTML builds a fresh variable bag for (user, project?,
// code, lang) and renders templateBytes to structural preview HTML — the
// draft-less counterpart of RenderPreviewWithMarker. Mirrors
// RenderProjectSubmission but (a) returns HTML, (b) tolerates a nil project
// (project-less fill: caption resolves from the submission_code's proceeding,
// t-paliad-364), and (c) takes a pluggable missing marker. Used by the
// base-preview modal opened from the catalog, where no draft exists yet
// (t-paliad-370 S3). No persistence.
func (s *SubmissionDraftService) RenderContextPreviewHTML(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, code, lang string, templateBytes []byte, missing MissingPlaceholderFn) (string, error) {
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: projectID,
SubmissionCode: code,
Lang: normalizeDraftLanguage(lang),
})
if err != nil {
return "", err
}
if missing == nil {
missing = DefaultMissingMarker(resolved.Lang)
}
return s.renderer.RenderHTML(templateBytes, resolved.Placeholders, missing)
}
// Export renders the merged .docx for download. Returns the bytes, the