ZDDC/browse/js/preview-zddc-form.js
ZDDC a0e467200e fix(browse): shorten the .zddc form intro so it doesn't dominate the space above Title
The intro <p class="help"> was a 2–3 sentence paragraph; in a narrower
preview pane it wrapped to ~9 lines (~170px tall), pushing the Title field
far down. It was also redundant with the read-only "Structure & advanced"
section + the "Edit raw YAML" button. Tighten it to one concise line
("Project options. Structural keys are read-only — use Edit raw YAML."):
now 20px wide / 41px on a narrow pane (was up to ~170px).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:11:28 -05:00

325 lines
16 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.

// preview-zddc-form.js — schema-driven FORM view for .zddc files.
//
// The user shouldn't have to understand YAML cascades to configure a project.
// This renders the .zddc as a form: the OPTION fields (the blanks an operator
// fills — title, admins, role members) are editable widgets; the STRUCTURE
// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only
// for context. The split is driven by the server's .zddc JSON Schema
// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited
// option values back into the file (preserving all structure keys) and PUTs the
// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member
// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the
// CodeMirror editor for anything the form doesn't cover (field_codes, display,
// convert, advanced acl).
//
// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is
// the power-user fallback.
(function (app) {
'use strict';
var util = app.modules.util || window.app.modules.util;
var escapeHtml = util.escapeHtml;
var saveFile = util.saveFile;
var isEditableZipMember = util.isEditableZipMember;
var current = null; // { node, dirty, etag, lastModified }
// Cached .zddc schema (property → {tier, description}).
var schemaProps = null;
function loadSchema() {
if (schemaProps) return Promise.resolve(schemaProps);
return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; })
.catch(function () { schemaProps = {}; return schemaProps; });
}
function handles(node) {
return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || ''));
}
function canSave(node) {
if (isEditableZipMember(node)) return true;
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
// A .zddc edit is an ActionAdmin write — needs the 'a' verb.
return window.zddc.cap.has(node, 'a');
}
return false;
}
function isDirty() { return !!(current && current.dirty); }
function currentNode() { return current ? current.node : null; }
function dispose() { current = null; }
function desc(name) {
return (schemaProps && schemaProps[name] && schemaProps[name].description) || '';
}
// ── small DOM helpers ───────────────────────────────────────────────────
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
// A growable list of single-string rows (used for admins + role members).
function listEditor(values, placeholder, onChange, readOnly) {
var wrap = el('div', 'zf-list');
function addRow(val) {
var row = el('div', 'zf-list__row');
row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;';
var input = el('input');
input.type = 'text';
input.value = val || '';
input.placeholder = placeholder || '';
input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);';
input.disabled = !!readOnly;
input.addEventListener('input', onChange);
row.appendChild(input);
if (!readOnly) {
var del = el('button', null, '');
del.type = 'button';
del.title = 'Remove';
del.addEventListener('click', function () { row.remove(); onChange(); });
row.appendChild(del);
}
wrap.appendChild(row);
}
(values || []).forEach(addRow);
if (!readOnly) {
var add = el('button', 'zf-add', '+ add');
add.type = 'button';
add.style.cssText = 'margin-top:.2rem;';
add.addEventListener('click', function () { addRow(''); onChange(); });
wrap.appendChild(add);
}
wrap._values = function () {
return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input'))
.map(function (i) { return i.value.trim(); })
.filter(function (v) { return v; });
};
return wrap;
}
async function render(node, container, ctx) {
dispose();
var text, etag = null, lastModified = null;
try {
if (ctx.getContentWithVersion) {
var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
etag = loaded.etag;
lastModified = loaded.lastModified;
} else {
text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node));
}
} catch (e) {
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '</div>';
return;
}
var data = {};
try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; }
if (typeof data !== 'object' || Array.isArray(data)) data = {};
await loadSchema();
var editable = canSave(node);
current = { node: node, dirty: false, etag: etag, lastModified: lastModified };
container.innerHTML = '';
var shell = el('div', 'yaml-shell zddc-form');
shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;';
container.appendChild(shell);
// Header.
var hdr = el('div', 'md-shell__infohdr');
hdr.appendChild(el('span', 'md-shell__title', node.name));
var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only'));
hdr.appendChild(srcTag);
var dirtyEl = el('span', 'md-shell__dirty');
hdr.appendChild(dirtyEl);
var statusEl = el('span', 'md-shell__status');
hdr.appendChild(statusEl);
var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML');
rawBtn.type = 'button';
rawBtn.title = 'Switch to the raw YAML editor (covers every key).';
rawBtn.addEventListener('click', function () {
var ym = window.app.modules.yamledit;
if (ym && ym.render) { dispose(); ym.render(node, container, ctx); }
});
hdr.appendChild(rawBtn);
var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save');
saveBtn.type = 'button';
saveBtn.disabled = true;
hdr.appendChild(saveBtn);
shell.appendChild(hdr);
function markDirty() {
if (!current) return;
current.dirty = true;
dirtyEl.textContent = '● modified';
if (editable) saveBtn.disabled = false;
}
var help = el('p', 'help');
help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .5rem;';
help.textContent = editable
? 'Project options. Structural keys are read-only — use Edit raw YAML.'
: 'Read-only — you need admin authority over this path to edit it.';
shell.appendChild(help);
// ── OPTION fields ───────────────────────────────────────────────────
function section(title, hint, tight) {
var s = el('section', 'zf-section');
s.style.cssText = 'margin:0 0 1rem;';
var h = el('h3', null, title);
// `tight` drops the heading's top margin for the FIRST section so
// it doesn't stack with the intro's bottom margin (the gap above
// Title was reading as excessive). Later sections keep the gap.
h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.6rem') + ' 0 .2rem;';
s.appendChild(h);
if (hint) {
var p = el('p', 'help', hint);
p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;';
s.appendChild(p);
}
shell.appendChild(s);
return s;
}
// title
var titleSec = section('Title', desc('title'), true);
var titleInput = el('input');
titleInput.type = 'text';
titleInput.value = (typeof data.title === 'string') ? data.title : '';
titleInput.disabled = !editable;
titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;';
titleInput.addEventListener('input', markDirty);
titleSec.appendChild(titleInput);
// admins
var adminsSec = section('Admins', desc('admins'));
var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable);
adminsSec.appendChild(adminsList);
// roles (map name → {members:[]})
var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.');
var rolesHost = el('div', 'zf-roles');
rolesSec.appendChild(rolesHost);
var roleEditors = []; // {name, membersEl, getName}
function addRole(name, members) {
var box = el('div', 'zf-role');
box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;';
var nameRow = el('div');
nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;';
var nameInput = el('input');
nameInput.type = 'text';
nameInput.value = name || '';
nameInput.placeholder = 'role name (e.g. document_controller)';
nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;';
nameInput.disabled = !editable;
nameInput.addEventListener('input', markDirty);
nameRow.appendChild(el('span', null, '👥'));
nameRow.appendChild(nameInput);
box.appendChild(nameRow);
var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable);
box.appendChild(membersList);
rolesHost.appendChild(box);
roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList });
}
var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {};
Object.keys(roles).forEach(function (rn) {
var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : [];
addRole(rn, m);
});
if (editable) {
var addRoleBtn = el('button', 'zf-add', '+ add role');
addRoleBtn.type = 'button';
addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); });
rolesSec.appendChild(addRoleBtn);
}
// ── STRUCTURE (read-only) ───────────────────────────────────────────
var structKeys = Object.keys(data).filter(function (k) {
return schemaProps[k] && schemaProps[k].tier === 'structure';
});
// Also surface option keys this form doesn't render yet, as read-only.
var rawHandled = { title: 1, admins: 1, roles: 1 };
var otherKeys = Object.keys(data).filter(function (k) {
return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure');
});
if (structKeys.length || otherKeys.length) {
var det = el('details', 'zf-structure');
det.style.cssText = 'margin-top:.5rem;';
var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)');
sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;';
det.appendChild(sum);
var subset = {};
structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; });
var pre = el('pre');
pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;';
try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); }
catch (_) { pre.textContent = JSON.stringify(subset, null, 2); }
det.appendChild(pre);
shell.appendChild(det);
}
// ── Save ────────────────────────────────────────────────────────────
function buildContent() {
var out = {};
// Preserve everything not managed by the form (structure + unrendered options).
Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; });
var t = titleInput.value.trim();
if (t) out.title = t;
var admins = adminsList._values();
if (admins.length) out.admins = admins;
var rolesOut = {};
roleEditors.forEach(function (re) {
var n = re.getName();
if (!n) return;
var mem = re.members._values();
rolesOut[n] = mem.length ? { members: mem } : { members: [] };
});
if (Object.keys(rolesOut).length) out.roles = rolesOut;
return window.jsyaml.dump(out);
}
saveBtn.addEventListener('click', async function () {
if (!current || !editable) return;
saveBtn.disabled = true;
statusEl.textContent = 'Saving…';
var content;
try { content = buildContent(); }
catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; }
try {
var res = await saveFile(node, content, 'application/yaml; charset=utf-8',
{ etag: current.etag, lastModified: current.lastModified });
if (!current) return;
current.etag = (res && res.etag) || current.etag;
current.dirty = false;
dirtyEl.textContent = '';
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success');
} catch (e) {
if (e && e.status === 412 && window.app.modules.conflict) {
window.app.modules.conflict.open({
name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); }
});
statusEl.textContent = 'Conflict — changed on server.';
} else {
statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e);
}
saveBtn.disabled = false;
}
});
}
app.modules.zddcform = {
handles: handles,
render: render,
isDirty: isDirty,
currentNode: currentNode,
dispose: dispose
};
})(window.app);