The first section's heading top margin (.6rem) stacked with the intro paragraph's bottom margin (.8rem), leaving ~1.4rem of dead space above the Title label. Drop the heading's top margin for the first section (new `tight` flag in section()) and trim the intro's bottom margin to .5rem. Later sections keep their inter-section gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
325 lines
16 KiB
JavaScript
325 lines
16 KiB
JavaScript
// 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
|
||
? 'Fill in the project options below. Structure (the folder shape, WORM, tools) is managed by the baseline and shown read-only — edit it via raw YAML if you must.'
|
||
: '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);
|