stage 1
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* Rendering module. All functions that manipulate the visible DOM live here.
|
||||
* Each render function reads from the shared state and writes to the cached
|
||||
* element references. The module has no side effects beyond DOM mutation.
|
||||
*/
|
||||
|
||||
import { state, elements, getCurrentReport, getTemplateRecord } from './state.js';
|
||||
import { createFieldNode } from './forms.js';
|
||||
import { validateReport } from './validation.js';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
formatRelativeTime,
|
||||
formatFileSize,
|
||||
prettifyStatus,
|
||||
badgeClassForTone
|
||||
} from './utils.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/* ── Top-level render orchestrator ──────────────────────────────────────── */
|
||||
|
||||
export function render(fieldCallbacks) {
|
||||
renderTemplateSelector();
|
||||
|
||||
/* User workspace renders — only when the report editor DOM exists. */
|
||||
if (elements.reportForm) {
|
||||
renderReportList();
|
||||
renderTemplateSummary();
|
||||
renderCurrentReport(fieldCallbacks);
|
||||
renderSyncSummary();
|
||||
renderImagePolicy();
|
||||
}
|
||||
|
||||
/* Admin workspace renders — only when the admin form DOM exists. */
|
||||
if (elements.adminImageRulesForm) {
|
||||
renderAdminImageRules();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Template selector ──────────────────────────────────────────────────── */
|
||||
|
||||
export function renderTemplateSelector() {
|
||||
const currentValue = state.selectedTemplateCode;
|
||||
elements.templateSelect.innerHTML = '';
|
||||
|
||||
if (!state.templatesCatalog.length) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = t('noTemplatesCached');
|
||||
elements.templateSelect.append(option);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const template of state.templatesCatalog) {
|
||||
const option = document.createElement('option');
|
||||
option.value = template.code;
|
||||
option.textContent = `${template.name} (v${template.activeVersion})`;
|
||||
option.selected = template.code === currentValue;
|
||||
elements.templateSelect.append(option);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Report list (F4 — with search & status filter) ─────────────────────── */
|
||||
|
||||
export function renderReportList() {
|
||||
if (!elements.reportList) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.reportList.innerHTML = '';
|
||||
|
||||
/* Apply search query and status filter. */
|
||||
let filtered = state.reports;
|
||||
|
||||
if (state.reportSearchQuery) {
|
||||
const q = state.reportSearchQuery.toLowerCase();
|
||||
filtered = filtered.filter((r) => {
|
||||
const num = (r.answers?.reportNumber || r.reportNumber || '').toLowerCase();
|
||||
const title = (r.title || '').toLowerCase();
|
||||
return num.includes(q) || title.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.reportFilterStatus) {
|
||||
filtered = filtered.filter((r) => r.status === state.reportFilterStatus);
|
||||
}
|
||||
|
||||
elements.reportCount.textContent = String(filtered.length);
|
||||
|
||||
if (!filtered.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'field-help';
|
||||
empty.textContent = state.reports.length ? 'No reports match the current filter.' : t('noLocalReports');
|
||||
elements.reportList.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const report of filtered) {
|
||||
const fragment = elements.reportListItemTemplate.content.cloneNode(true);
|
||||
const button = fragment.querySelector('.report-list-item');
|
||||
const title = fragment.querySelector('.report-list-item__title');
|
||||
const statusEl = fragment.querySelector('.report-list-item__status');
|
||||
const meta = fragment.querySelector('.report-list-item__meta');
|
||||
|
||||
title.textContent = report.answers.reportNumber || report.reportNumber;
|
||||
statusEl.textContent = prettifyStatus(report.status);
|
||||
statusEl.classList.add(`status-${report.status}`);
|
||||
meta.textContent = `${report.title} • Updated ${formatDateTime(report.updatedAt)}`;
|
||||
|
||||
if (report.id === state.currentReportId) {
|
||||
button.classList.add('is-active');
|
||||
}
|
||||
|
||||
button.dataset.reportId = report.id;
|
||||
elements.reportList.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Template summary cards ─────────────────────────────────────────────── */
|
||||
|
||||
export function renderTemplateSummary() {
|
||||
if (!elements.summaryTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const report = getCurrentReport();
|
||||
const templateCode = report?.templateCode || state.selectedTemplateCode;
|
||||
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
|
||||
|
||||
if (!catalogEntry) {
|
||||
elements.summaryTemplate.textContent = 'Not loaded';
|
||||
elements.summaryVersion.textContent = 'Version -';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.summaryTemplate.textContent = catalogEntry.name;
|
||||
elements.summaryVersion.textContent = report
|
||||
? `Version ${report.templateVersion}`
|
||||
: `Version ${catalogEntry.activeVersion}`;
|
||||
}
|
||||
|
||||
export function renderSyncSummary() {
|
||||
if (!elements.syncHeadline) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.syncHeadline.textContent = state.lastSyncAt
|
||||
? `Last sync ${formatRelativeTime(state.lastSyncAt)}`
|
||||
: 'No sync yet';
|
||||
elements.syncDetail.textContent = state.lastSyncAt
|
||||
? `Cached template data from ${formatDateTime(state.lastSyncAt)}`
|
||||
: 'Templates are cached locally after the first successful sync.';
|
||||
}
|
||||
|
||||
/* ── Image policy ───────────────────────────────────────────────────────── */
|
||||
|
||||
export function renderImagePolicy() {
|
||||
if (!elements.imagePolicyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.imageRules) {
|
||||
elements.imagePolicyText.textContent = t('imagePolicyHint');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.imagePolicyText.textContent = `${state.imageRules.name}: ${state.imageRules.allowedMimeTypes.join(
|
||||
', '
|
||||
)}, max ${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, behavior ${state.imageRules.oversizeBehavior}.`;
|
||||
}
|
||||
|
||||
/* ── Admin image rules ──────────────────────────────────────────────────── */
|
||||
|
||||
export function renderAdminImageRules() {
|
||||
if (!elements.adminSyncState) {
|
||||
return;
|
||||
}
|
||||
|
||||
populateAdminImageRulesForm(state.imageRules);
|
||||
|
||||
if (!state.imageRules) {
|
||||
elements.adminSyncState.textContent = t('noImageRulesLoaded');
|
||||
elements.adminSyncState.className = 'badge badge-offline';
|
||||
elements.adminPolicyCode.textContent = '-';
|
||||
elements.adminPolicyMimeTypes.textContent = '-';
|
||||
elements.adminPolicyOptimization.textContent = '-';
|
||||
elements.adminPolicyLimits.textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.adminSyncState.textContent = navigator.onLine ? t('liveConfig') : t('offlineCachedConfig');
|
||||
elements.adminSyncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
|
||||
elements.adminPolicyCode.textContent = state.imageRules.code;
|
||||
elements.adminPolicyMimeTypes.textContent = state.imageRules.allowedMimeTypes.join(', ');
|
||||
elements.adminPolicyOptimization.textContent = `${state.imageRules.oversizeBehavior}, JPEG ${state.imageRules.jpegQuality}%`;
|
||||
elements.adminPolicyLimits.textContent = `${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, ${state.imageRules.maxAttachmentsPerField} attachment(s)`;
|
||||
}
|
||||
|
||||
export function populateAdminImageRulesForm(imageRules) {
|
||||
if (!imageRules || !elements.adminPolicyName) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.adminPolicyName.value = imageRules.name || '';
|
||||
elements.adminAllowedMimeTypes.value = (imageRules.allowedMimeTypes || []).join(', ');
|
||||
elements.adminMaxFileSizeMb.value = String((Number(imageRules.maxFileSizeBytes || 0) / (1024 * 1024)).toFixed(1));
|
||||
elements.adminMaxAttachmentsPerField.value = String(imageRules.maxAttachmentsPerField || 1);
|
||||
elements.adminMaxWidthPx.value = String(imageRules.maxWidthPx || '');
|
||||
elements.adminMaxHeightPx.value = String(imageRules.maxHeightPx || '');
|
||||
elements.adminJpegQuality.value = String(imageRules.jpegQuality || '');
|
||||
elements.adminOversizeBehavior.value = imageRules.oversizeBehavior || 'auto_optimize';
|
||||
}
|
||||
|
||||
/* ── Current report ─────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* `renderCurrentReport` accepts a `fieldCallbacks` object so the form module
|
||||
* can invoke actions (field change, attach, remove) owned by the orchestrator
|
||||
* without creating circular imports.
|
||||
*/
|
||||
export function renderCurrentReport(fieldCallbacks = {}) {
|
||||
if (!elements.reportForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const report = getCurrentReport();
|
||||
|
||||
if (!report) {
|
||||
elements.heroTitle.textContent = t('noReportSelected');
|
||||
elements.heroSubtitle.textContent = t('noReportSelectedHint');
|
||||
elements.reportStatusSelect.value = 'draft';
|
||||
elements.reportStatusSelect.disabled = true;
|
||||
elements.deleteReportButton.disabled = true;
|
||||
if (elements.submitReportButton) elements.submitReportButton.disabled = true;
|
||||
if (elements.exportReportButton) elements.exportReportButton.disabled = true;
|
||||
elements.editorHint.textContent = 'Dynamic form rendering from template JSON';
|
||||
elements.reportForm.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No report open</h3>
|
||||
<p>Choose a template and create a report to start editing locally.</p>
|
||||
</div>
|
||||
`;
|
||||
renderMeta(null);
|
||||
renderValidation();
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
||||
elements.reportStatusSelect.disabled = false;
|
||||
elements.reportStatusSelect.value = report.status;
|
||||
elements.deleteReportButton.disabled = false;
|
||||
if (elements.submitReportButton) elements.submitReportButton.disabled = false;
|
||||
if (elements.exportReportButton) elements.exportReportButton.disabled = false;
|
||||
elements.heroTitle.textContent = report.answers.reportNumber || report.reportNumber;
|
||||
elements.heroSubtitle.textContent = `${template?.name || report.templateCode} • Local draft bound to template version ${report.templateVersion}`;
|
||||
elements.editorHint.textContent = `${state.currentAttachments.length} attachment(s) stored in IndexedDB for this report`;
|
||||
|
||||
if (!template) {
|
||||
elements.reportForm.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>Template missing</h3>
|
||||
<p>This draft is bound to template version ${report.templateVersion}, but that definition is not cached locally.</p>
|
||||
</div>
|
||||
`;
|
||||
renderMeta(report);
|
||||
renderValidation();
|
||||
return;
|
||||
}
|
||||
|
||||
elements.reportForm.innerHTML = '';
|
||||
|
||||
for (const section of template.definition.sections || []) {
|
||||
const sectionNode = document.createElement('section');
|
||||
sectionNode.className = 'template-section';
|
||||
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'section-heading-row';
|
||||
heading.innerHTML = `<h3>${escapeHtml(section.title)}</h3><span class="panel-note">${section.fields.length} field(s)</span>`;
|
||||
sectionNode.append(heading);
|
||||
|
||||
const fieldGrid = document.createElement('div');
|
||||
fieldGrid.className = 'field-grid';
|
||||
|
||||
for (const field of section.fields || []) {
|
||||
fieldGrid.append(createFieldNode(field, report, {
|
||||
state,
|
||||
onFieldChange: fieldCallbacks.onFieldChange || (() => {}),
|
||||
onAttachFiles: fieldCallbacks.onAttachFiles || (() => {}),
|
||||
onRemoveAttachment: fieldCallbacks.onRemoveAttachment || (() => {})
|
||||
}));
|
||||
}
|
||||
|
||||
sectionNode.append(fieldGrid);
|
||||
elements.reportForm.append(sectionNode);
|
||||
}
|
||||
|
||||
renderMeta(report);
|
||||
renderValidation();
|
||||
}
|
||||
|
||||
/* ── Meta & validation ──────────────────────────────────────────────────── */
|
||||
|
||||
export function renderMeta(report) {
|
||||
if (!elements.reportMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = report
|
||||
? [
|
||||
report.id,
|
||||
`${report.templateCode} v${report.templateVersion}`,
|
||||
formatDateTime(report.createdAt),
|
||||
formatDateTime(report.updatedAt)
|
||||
]
|
||||
: ['-', '-', '-', '-'];
|
||||
|
||||
elements.reportMeta.querySelectorAll('dd').forEach((node, index) => {
|
||||
node.textContent = values[index];
|
||||
});
|
||||
}
|
||||
|
||||
export function renderValidation() {
|
||||
if (!elements.validationHeadline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const report = getCurrentReport();
|
||||
|
||||
if (!report) {
|
||||
elements.validationHeadline.textContent = t('noReportSelected');
|
||||
elements.validationDetail.textContent = 'Draft validation will appear here.';
|
||||
elements.validationList.innerHTML = '<li>No report selected.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
||||
|
||||
if (!template) {
|
||||
elements.validationHeadline.textContent = t('templateUnavailable');
|
||||
elements.validationDetail.textContent = t('validationNeedsTemplate');
|
||||
elements.validationList.innerHTML = `<li>${t('templateMissing')}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = validateReport(report, template, state.currentAttachments);
|
||||
|
||||
if (!issues.length) {
|
||||
elements.validationHeadline.textContent = t('readyForExport');
|
||||
elements.validationDetail.textContent = t('noBlockingIssues');
|
||||
elements.validationList.innerHTML = `<li>${t('noValidationIssues')}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.validationHeadline.textContent = t('issueCount', issues.length);
|
||||
elements.validationDetail.textContent =
|
||||
report.status === 'ready_for_export' ? t('issueReadyWarning') : t('issueDraftHint');
|
||||
elements.validationList.innerHTML = '';
|
||||
|
||||
for (const issue of issues) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = issue;
|
||||
elements.validationList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Badge helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
export function updateConnectionBadge() {
|
||||
if (navigator.onLine) {
|
||||
elements.connectionBadge.textContent = t('online');
|
||||
elements.connectionBadge.className = 'badge badge-online';
|
||||
} else {
|
||||
elements.connectionBadge.textContent = t('offline');
|
||||
elements.connectionBadge.className = 'badge badge-offline';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSaveBadge(text, tone) {
|
||||
elements.saveBadge.textContent = text;
|
||||
elements.saveBadge.className = `badge ${badgeClassForTone(tone)}`;
|
||||
}
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
Reference in New Issue
Block a user