stage 1
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user