ZDDC/browse/js/plan-review.js
ZDDC bbbf5326e7 refactor(browse): consolidate duplicated helpers into util.js; fix YAML save divergence
Nine copies of escapeHtml (some escaping single-quotes + handling null,
others not), two byte-identical hashContent hashers, two saveContent
writers, two isZipMemberNode predicates, the ISO-date + YAML-quote helpers
duplicated across the workflow modals, three /.profile/access email
fetchers, and three byte-size formatters had all drifted across the browse
modules. Hoist a single browse-local window.app.modules.util (no new global;
concatenated right after init.js) and alias the call sites to it.

Reliability fix folded in: the YAML editor's saveContent skipped the
upload.ensureWritable() escalation that the markdown editor performs, so
saving a .yaml/.zddc file to a read-only-picked local folder failed where
markdown succeeded. Both now go through util.saveFile, which always
escalates — the shared writer makes the two editors impossible to drift
apart again.

Canonical escapeHtml is the strict superset (escapes & < > " ', null →
"") so it's a safe drop-in for every prior variant. fmtSize gains the GB
tier everywhere (history.js previously capped at MB). Also removes the dead
stage.js fetchSelfEmail (defined, never called).

Net −200 lines across the modules. No behavior change beyond the save fix;
all 6 browse Playwright specs pass.

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

250 lines
11 KiB
JavaScript

// plan-review.js — the doc-controller "Plan Review" workflow modal.
//
// Surfaced by events.js as a right-click menu item on
// archive/<party>/received/<tracking>/ folders when the cascade above
// has an on_plan_review block (X-ZDDC-On-Plan-Review header on the
// listing).
//
// The modal collects four fields:
//
// - review_lead (becomes sub-admin of reviewing/<…>/)
// - plan_review_complete_date (the committed review-done date)
// - approver (becomes sub-admin of staging/<…>/)
// - plan_response_date (the committed response-issue date)
//
// The planned dates are immutable from the sub-admins' perspective —
// they live in the canonical submittal's .zddc
// (received/<tracking>/.zddc) where only the doc controller (via Plan
// Review re-run) can change them. The workflow folders' .zddc files
// carry only the back-link + per-folder ACL.
//
// Title is auto-derived server-side from the first ZDDC-parseable
// file in received/<tracking>/. Forecast dates default to the planned
// dates at scaffolding time; the user renames the workflow folder
// directly to update the forecast later.
//
// On submit, the form assembles a YAML body and POSTs it with
// X-ZDDC-Op: plan-review to the received/<tracking>/ URL.
(function () {
'use strict';
var REVIEW_OFFSET_DAYS = 7;
var RESPONSE_OFFSET_DAYS = 14;
// Notifications go through the shared toast helper — there's no
// persistent footer strip in browse anymore.
function statusInfo(msg) {
if (msg && window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, 'info');
}
}
function statusError(msg) {
if (msg && window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, 'error');
}
}
var util = window.app.modules.util;
var isoDatePlus = util.isoDatePlus;
// Fetch suggestion emails from /.profile/access so the originator
// field has a datalist of likely values. Best-effort — silent on
// failure (the field still accepts free text).
var fetchOriginatorSuggestions = util.fetchAccessEmails;
// Build the YAML body for the plan-review POST. Quoting is minimal
// (just enough for emails with special chars).
function buildBody(values) {
var yamlString = util.yamlQuote;
return [
'review_lead: ' + yamlString(values.reviewLead),
'approver: ' + yamlString(values.approver),
'plan_review_complete_date: ' + values.planReviewDate,
'plan_response_date: ' + values.planResponseDate,
''
].join('\n');
}
// Render the modal. Returns a Promise that resolves on submit
// (with the collected values) or rejects on cancel.
function openForm(initial) {
return new Promise(function (resolve, reject) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:24rem;max-width:32rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;';
box.innerHTML =
'<h2 style="margin:0 0 0.75rem 0;font-size:1.1rem;">Plan Review — ' + escapeHtml(initial.tracking) + '</h2>' +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="pr-review-lead">Review lead</label>' +
'<input id="pr-review-lead" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
'<label for="pr-review-date">Plan review complete date</label>' +
'<input id="pr-review-date" type="date" required>' +
'<label for="pr-approver">Approver</label>' +
'<input id="pr-approver" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
'<label for="pr-response-date">Plan response date</label>' +
'<input id="pr-response-date" type="date" required>' +
'<datalist id="pr-people-list"></datalist>' +
'</div>' +
'<p style="margin:0.75rem 0 0 0;font-size:0.8rem;color:#666;">Planned dates seal at first submission — they become part of the canonical record (received/<tracking>/.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.</p>' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="pr-cancel">Cancel</button>' +
'<button type="button" id="pr-submit" class="btn-primary">Plan Review</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var reviewLeadInput = box.querySelector('#pr-review-lead');
var approverInput = box.querySelector('#pr-approver');
var reviewDateInput = box.querySelector('#pr-review-date');
var responseDateInput = box.querySelector('#pr-response-date');
reviewDateInput.value = isoDatePlus(REVIEW_OFFSET_DAYS);
responseDateInput.value = isoDatePlus(RESPONSE_OFFSET_DAYS);
// Populate the datalist with people suggestions (best
// effort — silent on failure).
fetchOriginatorSuggestions().then(function (emails) {
var dl = box.querySelector('#pr-people-list');
if (!dl) return;
emails.forEach(function (e) {
var opt = document.createElement('option');
opt.value = e;
dl.appendChild(opt);
});
});
// Escape handler bound once, removed in close() — every
// dismissal path routes through close() so the document
// listener never outlives the modal.
function onKeydown(e) {
if (e.key === 'Escape') { close(); reject(new Error('cancelled')); }
}
function close() {
document.removeEventListener('keydown', onKeydown);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
box.querySelector('#pr-cancel').addEventListener('click', function () {
close();
reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) {
close();
reject(new Error('cancelled'));
}
});
document.addEventListener('keydown', onKeydown);
box.querySelector('#pr-submit').addEventListener('click', function () {
var values = {
reviewLead: reviewLeadInput.value.trim(),
approver: approverInput.value.trim(),
planReviewDate: reviewDateInput.value,
planResponseDate: responseDateInput.value
};
if (!values.reviewLead || !values.approver
|| !values.planReviewDate || !values.planResponseDate) {
statusError('All fields are required.');
return;
}
close();
resolve(values);
});
reviewLeadInput.focus();
});
}
var escapeHtml = util.escapeHtml;
// Detect whether a tree node is an archive/<party>/received/<tracking>/
// folder. The path is path-shaped, not content-based — tracking-number
// content is not inspected (per design).
function isReceivedTrackingFolder(node) {
if (!node || !node.isDir) return false;
var tree = window.app.modules.tree;
if (!tree) return false;
var p = tree.pathFor(node).replace(/\/$/, '');
var rel = p.replace(/^\/+/, '');
var parts = rel.split('/');
return parts.length === 5
&& parts[1].toLowerCase() === 'archive'
&& parts[3].toLowerCase() === 'received';
}
var busy = false;
// Run the Plan Review flow: open the modal, POST the result.
async function invoke(node) {
if (busy) return;
var tree = window.app.modules.tree;
if (!tree) return;
var url = tree.pathFor(node);
if (!url.endsWith('/')) url += '/';
var parts = url.replace(/^\/+/, '').replace(/\/$/, '').split('/');
var tracking = parts[parts.length - 1];
var values;
try {
values = await openForm({ tracking: tracking });
} catch (_e) {
return; // cancelled
}
busy = true;
try {
statusInfo('Plan Review — submitting…');
var body = buildBody(values);
var resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'plan-review',
'Content-Type': 'application/yaml'
},
body: body,
credentials: 'same-origin'
});
} catch (e) {
statusError('Plan Review failed: ' + (e && e.message ? e.message : e));
return;
}
if (!resp.ok) {
var text = '';
try { text = await resp.text(); } catch (_e) { /* ignore */ }
statusError('Plan Review failed (' + resp.status + '): ' + text);
return;
}
var data;
try { data = await resp.json(); } catch (_e) { data = null; }
if (data && data.reviewing && data.staging) {
var rPart = data.reviewing.created ? 'created' : 'updated';
var sPart = data.staging.created ? 'created' : 'updated';
var seal = (data.received && data.received.created)
? ' Canonical record sealed.'
: (data.received && !data.received.zddc_written)
? ' Canonical dates left untouched (already sealed).'
: '';
statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal +
' Reload the relevant folder to see the new entries.');
} else {
statusInfo('Plan Review complete.');
}
} finally {
busy = false;
}
}
window.app.modules.planReview = {
isReceivedTrackingFolder: isReceivedTrackingFolder,
invoke: invoke
};
})();