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>
250 lines
11 KiB
JavaScript
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
|
|
};
|
|
})();
|