ZDDC/form/js/array.js
ZDDC a02a26d3c2
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
feat: form-data system v0 (sixth tool + zddc-server endpoints)
Schema-driven form renderer plus zddc-server endpoints that turn any
<name>.form.yaml into a working data-collection form at <path>/<name>.form.html.
Submissions land in <path>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml,
ACL-gated by the existing .zddc cascade. The form posts back to its own URL;
the server strips ".html" and routes by what's underneath, so create and
update use the same client-side code path.

Form spec dialect: JSON Schema 2020-12 + RJSF-style ui:* hints, written in
YAML. Chosen for LLM authorability — it's the canonical structured-output
target for OpenAI/Anthropic, and the ui:* convention is the most-trained UI
hint vocabulary. Supported subset for v0: type (string/number/integer/boolean/
array/object), enum, min/max, minLength/maxLength, required, additionalProperties:
false, properties, items, format (date, email). Round-trip mode is form-as-truth:
submission YAML is regenerated each save, comments are not preserved (the v1
file-as-truth mode for hand-edited files like .zddc itself is deferred).

New components:
  * form/ — sixth single-file HTML tool, vanilla JS renderer (~760 LoC)
  * zddc/internal/jsonschema/ — focused JSON Schema validator covering only
    the v0 keyword subset. Match-implementation-cost-to-surface-used: a full
    library brings 70%+ surface we don't use; revisit when v1 adds $ref +
    oneOf + if/then/else.
  * zddc/internal/handler/formhandler.go — RecognizeFormRequest / ServeForm,
    capability-URL re-edit, atomic submission writes via the new
    zddc.WriteAtomic helper extracted from writer.go.
  * dispatch() in zddc-server/main.go now intercepts *.form.html and
    *.yaml.html before the static-file path; spec existence is the trigger.

Build pipeline: form joins ZDDC_RELEASE_TOOLS in lockstep, gets its own
embedded copy in handler/form.html (separate from the apps cascade —
the form renderer is fixed, not subject to per-folder version overrides).

Tests: 5 new Playwright specs (form-safety) + 14 new Go tests across the
validator and handler. All 172 Playwright tests + 10 Go packages green.
End-to-end manual verification: GET empty → POST 201 + capability URL →
GET re-edit (pre-filled) → POST update → 200, raw YAML browsable, ACL
deny → 403.

Docs: form/ section added to AGENTS.md and ARCHITECTURE.md. AGENTS.md
also documents the implementation-vs-dependency policy. CLAUDE.md repo-shape
list extended.

Deferred (v1+): .zddc editor migration onto this system, file-as-truth
lossless YAML round-trip, ui:show-when conditional visibility, oneOf/anyOf,
apps-cascade preview hook, cascade-fetched form definitions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:12:16 -05:00

127 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function (app) {
'use strict';
const u = app.modules.util;
function makeArray(schema, ui, path, value, options) {
const wrap = u.h('div', { className: 'form-field form-array' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
if (label) {
const lbl = u.h('label', { className: 'form-field__label' });
lbl.appendChild(document.createTextNode(label));
if (options.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (schema.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
const rowsEl = u.h('div', { className: 'form-array__rows' });
wrap.appendChild(rowsEl);
const itemSchema = schema.items || { type: 'string' };
const itemUi = (ui && ui.items) || {};
const uiOpts = (ui && ui['ui:options']) || {};
const addable = uiOpts.addable !== false;
const removable = uiOpts.removable !== false;
const rows = [];
function repath() {
for (let i = 0; i < rows.length; i++) {
rows[i].widget.path = u.ptrPush(path, String(i));
}
}
function addRow(rowValue) {
const idx = rows.length;
const rowPath = u.ptrPush(path, String(idx));
const childWidget = app.modules.render.create(itemSchema, itemUi, rowPath, rowValue, {
fieldName: '',
required: false
});
const rowEl = u.h('div', { className: 'form-array__row' });
const body = u.h('div', { className: 'form-array__row-body' });
body.appendChild(childWidget.el);
rowEl.appendChild(body);
if (removable) {
const actions = u.h('div', { className: 'form-array__row-actions' });
const removeBtn = u.h('button', {
type: 'button',
className: 'btn btn-small',
title: 'Remove this row',
onClick: function () { removeRow(rowEl); }
}, '×');
actions.appendChild(removeBtn);
rowEl.appendChild(actions);
}
rows.push({ widget: childWidget, rowEl: rowEl });
rowsEl.appendChild(rowEl);
}
function removeRow(targetEl) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].rowEl === targetEl) {
rows.splice(i, 1);
targetEl.remove();
repath();
return;
}
}
}
const initial = Array.isArray(value) ? value : [];
for (let i = 0; i < initial.length; i++) {
addRow(initial[i]);
}
if (addable) {
const addBtn = u.h('button', {
type: 'button',
className: 'btn btn-small form-array__add',
onClick: function () { addRow(undefined); }
}, '+ Add');
wrap.appendChild(addBtn);
}
return {
el: wrap,
path: path,
type: 'array',
read: function () {
const out = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].widget.read();
if (v !== undefined) {
out.push(v);
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
for (let i = 0; i < rows.length; i++) {
rows[i].widget.clearErrors();
}
},
child: function (idxStr) {
const i = parseInt(idxStr, 10);
return (rows[i] && rows[i].widget) || null;
}
};
}
app.modules.array = { makeArray: makeArray };
})(window.formApp);