// 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 = '
' + 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '
'; 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);