240 lines
7.9 KiB
JavaScript
240 lines
7.9 KiB
JavaScript
/*
|
|
* Dynamic form field creation. Each function returns a DOM node tree for a
|
|
* single template field. The approach keeps template-driven rendering in one
|
|
* module while the orchestrator (app.js) provides callbacks for state mutations.
|
|
*
|
|
* Callback contract:
|
|
* onFieldChange(field, nextValue) — called when the user edits a field
|
|
* onAttachFiles(field, report, files) — called when files are selected
|
|
* onRemoveAttachment(attachmentId) — called to remove an attachment
|
|
*/
|
|
|
|
import { evaluateRequiredWhen } from './validation.js';
|
|
import { formatFileSize } from './utils.js';
|
|
|
|
export function createFieldNode(field, report, { state, onFieldChange, onAttachFiles, onRemoveAttachment }) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = `field ${field.type === 'comment' || field.type === 'attachment' ? 'field-full' : ''}`;
|
|
|
|
const isRequired = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
|
|
const currentValue = report.answers[field.id];
|
|
|
|
wrapper.innerHTML = `
|
|
<div class="field-header">
|
|
<label class="field-label" for="field-${field.id}">${escapeHtml(field.label)}</label>
|
|
${isRequired ? '<span class="required-pill">Required</span>' : ''}
|
|
</div>
|
|
`;
|
|
|
|
let inputNode;
|
|
|
|
switch (field.type) {
|
|
case 'text':
|
|
case 'date':
|
|
case 'number':
|
|
inputNode = document.createElement('input');
|
|
inputNode.className = 'text-input';
|
|
inputNode.id = `field-${field.id}`;
|
|
inputNode.name = field.id;
|
|
inputNode.type = field.type;
|
|
inputNode.value = currentValue ?? '';
|
|
inputNode.readOnly = Boolean(field.readOnly);
|
|
|
|
if (field.validation?.min !== undefined) {
|
|
inputNode.min = String(field.validation.min);
|
|
}
|
|
|
|
inputNode.addEventListener('input', (event) => {
|
|
onFieldChange(field, event.target.value);
|
|
});
|
|
|
|
break;
|
|
|
|
case 'lookup': {
|
|
inputNode = document.createElement('select');
|
|
inputNode.className = 'select-input';
|
|
inputNode.id = `field-${field.id}`;
|
|
inputNode.name = field.id;
|
|
|
|
const emptyOption = document.createElement('option');
|
|
emptyOption.value = '';
|
|
emptyOption.textContent = 'Select an option';
|
|
inputNode.append(emptyOption);
|
|
|
|
const lookup = state.lookups.get(field.lookupCode);
|
|
|
|
for (const optionData of lookup?.values || []) {
|
|
const option = document.createElement('option');
|
|
option.value = optionData.value;
|
|
option.textContent = optionData.label;
|
|
option.selected = optionData.value === currentValue;
|
|
inputNode.append(option);
|
|
}
|
|
|
|
inputNode.addEventListener('change', (event) => {
|
|
onFieldChange(field, event.target.value);
|
|
});
|
|
|
|
break;
|
|
}
|
|
|
|
case 'checkbox':
|
|
inputNode = document.createElement('label');
|
|
inputNode.className = 'checkbox-row';
|
|
inputNode.innerHTML = `
|
|
<input id="field-${field.id}" name="${field.id}" type="checkbox" ${currentValue ? 'checked' : ''} />
|
|
<span>${escapeHtml(field.label)}</span>
|
|
`;
|
|
|
|
inputNode.querySelector('input').addEventListener('change', (event) => {
|
|
onFieldChange(field, event.target.checked);
|
|
});
|
|
|
|
break;
|
|
|
|
case 'comment':
|
|
inputNode = document.createElement('textarea');
|
|
inputNode.className = 'text-area';
|
|
inputNode.id = `field-${field.id}`;
|
|
inputNode.name = field.id;
|
|
inputNode.maxLength = field.maxLength || 5000;
|
|
inputNode.value = currentValue ?? '';
|
|
|
|
inputNode.addEventListener('input', (event) => {
|
|
onFieldChange(field, event.target.value);
|
|
});
|
|
|
|
break;
|
|
|
|
case 'attachment':
|
|
inputNode = createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment });
|
|
break;
|
|
|
|
default:
|
|
inputNode = document.createElement('input');
|
|
inputNode.className = 'text-input';
|
|
inputNode.id = `field-${field.id}`;
|
|
inputNode.name = field.id;
|
|
inputNode.type = 'text';
|
|
inputNode.value = currentValue ?? '';
|
|
|
|
inputNode.addEventListener('input', (event) => {
|
|
onFieldChange(field, event.target.value);
|
|
});
|
|
}
|
|
|
|
wrapper.append(inputNode);
|
|
|
|
if (field.requiredWhen?.field) {
|
|
const help = document.createElement('p');
|
|
help.className = 'field-help';
|
|
help.textContent = `Required when ${field.requiredWhen.field} is ${String(field.requiredWhen.equals)}.`;
|
|
wrapper.append(help);
|
|
}
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/* ── Attachment field ───────────────────────────────────────────────────── */
|
|
|
|
function createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }) {
|
|
const container = document.createElement('div');
|
|
container.className = 'attachment-list';
|
|
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'attachment-toolbar';
|
|
|
|
const input = document.createElement('input');
|
|
input.className = 'file-input';
|
|
input.id = `field-${field.id}`;
|
|
input.type = 'file';
|
|
input.accept = state.imageRules?.allowedMimeTypes?.join(',') || 'image/*';
|
|
input.multiple = true;
|
|
|
|
input.addEventListener('change', async (event) => {
|
|
const files = Array.from(event.target.files || []);
|
|
await onAttachFiles(field, report, files);
|
|
event.target.value = '';
|
|
});
|
|
|
|
toolbar.append(input);
|
|
container.append(toolbar);
|
|
|
|
/*
|
|
* P3 — Lazy-load attachment previews. Thumbnails are created from object URLs
|
|
* on demand using IntersectionObserver. Only attachments scrolled into view
|
|
* allocate a Blob URL, keeping memory use proportional to the visible area.
|
|
*/
|
|
const attachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
|
|
|
|
if (!attachments.length) {
|
|
const hint = document.createElement('p');
|
|
hint.className = 'field-help';
|
|
hint.textContent = 'No images attached yet.';
|
|
container.append(hint);
|
|
return container;
|
|
}
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) {
|
|
continue;
|
|
}
|
|
|
|
const img = entry.target;
|
|
const attachment = attachments.find((a) => a.id === img.dataset.attachmentId);
|
|
|
|
if (attachment?.blob) {
|
|
const objectUrl = URL.createObjectURL(attachment.blob);
|
|
img.src = objectUrl;
|
|
img.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true });
|
|
}
|
|
|
|
observer.unobserve(img);
|
|
}
|
|
}, { rootMargin: '200px' });
|
|
|
|
for (const attachment of attachments) {
|
|
const card = document.createElement('article');
|
|
card.className = 'attachment-card';
|
|
|
|
const preview = document.createElement('img');
|
|
preview.className = 'attachment-preview';
|
|
preview.alt = attachment.generatedFilename;
|
|
preview.dataset.attachmentId = attachment.id;
|
|
/* Actual src loaded lazily by the IntersectionObserver above. */
|
|
|
|
observer.observe(preview);
|
|
|
|
const copy = document.createElement('div');
|
|
copy.className = 'attachment-card__copy';
|
|
copy.innerHTML = `
|
|
<strong>${escapeHtml(attachment.generatedFilename)}</strong>
|
|
<span>${escapeHtml(attachment.originalFilename)}</span>
|
|
<span>${attachment.width}x${attachment.height}px • ${formatFileSize(attachment.sizeBytes)}</span>
|
|
`;
|
|
|
|
const removeButton = document.createElement('button');
|
|
removeButton.type = 'button';
|
|
removeButton.className = 'button button-small button-ghost';
|
|
removeButton.textContent = 'Remove';
|
|
|
|
removeButton.addEventListener('click', () => {
|
|
void onRemoveAttachment(attachment.id);
|
|
});
|
|
|
|
card.append(preview, copy, removeButton);
|
|
container.append(card);
|
|
}
|
|
|
|
return container;
|
|
}
|
|
|
|
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|