ZDDC/tables/js/api-actions.js
2026-06-11 13:32:31 -05:00

257 lines
12 KiB
JavaScript

// api-actions.js — generic "tables over an API collection" layer.
//
// When the injected #table-context carries an `apiActions` block, this turns
// the otherwise read-only table into a managed collection backed by a REST
// endpoint, WITHOUT touching the file-save/row-ops machinery (which is bound
// to <dir>/*.yaml row files). It drives create + per-row delete against the
// configured URLs and reloads on success (the server re-renders the fresh
// list). First consumer: the self-service token page at /.tokens.
//
// apiActions: {
// create: { url, title?, fields:[{name,label,placeholder?,type?}], secretField?, secretLabel? },
// deleteRow: { urlTemplate (with {id}), label?, confirm? } // {id} ← row's data-url
// }
(function (app) {
'use strict';
function ctxObj() {
return (app && app.context) || {};
}
function cfg() {
return ctxObj().apiActions || null;
}
// Active when the table is an API collection (apiActions) OR a read-only
// server-injected view (readOnly) — either way the file-model toolbar
// buttons (+ Add row / Save) don't apply and are hidden.
function active() {
return !!(cfg() || ctxObj().readOnly);
}
function el(tag, attrs, text) {
var e = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); });
if (text != null) e.textContent = text;
return e;
}
// ── Create ────────────────────────────────────────────────────────────
var createMounted = false;
function mountCreate(c) {
if (createMounted) return;
var bar = document.querySelector('.table-toolbar__left') || document.getElementById('table-toolbar');
if (!bar) return;
// The native "+ Add row" posts to the form-create file endpoint, which
// doesn't apply to an API collection — hide it; this button replaces it.
var native = document.getElementById('table-add-row');
if (native) native.hidden = true;
var btn = el('button', { type: 'button', class: 'btn btn-primary btn-sm', id: 'api-create-btn' }, '+ ' + (c.title || 'New'));
btn.addEventListener('click', function () { openCreate(c); });
bar.appendChild(btn);
createMounted = true;
}
function openCreate(c) {
var overlay = el('div', { class: 'api-modal__overlay' });
var modal = el('div', { class: 'api-modal' });
modal.appendChild(el('h2', { class: 'api-modal__title' }, c.title || 'New'));
var form = el('form', { class: 'api-modal__form' });
var inputs = {};
(c.fields || []).forEach(function (f) {
var lab = el('label', { class: 'api-modal__field' });
lab.appendChild(el('span', null, (f.label || f.name) + (f.required ? ' *' : '')));
var inp = el('input', { type: f.type || 'text' });
if (f.placeholder) inp.setAttribute('placeholder', f.placeholder);
if (f.required) inp.required = true;
inputs[f.name] = inp;
lab.appendChild(inp);
form.appendChild(lab);
});
var err = el('div', { class: 'api-modal__err', hidden: 'hidden' });
form.appendChild(err);
var actions = el('div', { class: 'api-modal__actions' });
var cancel = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Cancel');
var submit = el('button', { type: 'submit', class: 'btn btn-primary btn-sm' }, 'Create');
actions.appendChild(cancel); actions.appendChild(submit);
form.appendChild(actions);
modal.appendChild(form);
overlay.appendChild(modal);
document.body.appendChild(overlay);
var firstInput = form.querySelector('input');
if (firstInput) firstInput.focus();
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
cancel.addEventListener('click', close);
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
form.addEventListener('submit', function (e) {
e.preventDefault();
err.hidden = true;
var missing = (c.fields || []).filter(function (f) { return f.required && !inputs[f.name].value.trim(); });
if (missing.length) {
err.textContent = 'Required: ' + missing.map(function (f) { return f.label || f.name; }).join(', ');
err.hidden = false;
return;
}
var body = {};
(c.fields || []).forEach(function (f) {
var v = inputs[f.name].value.trim();
if (!v) return;
// Date fields → RFC3339 so the Go time.Time decoder accepts them.
body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v;
});
// Constant fields the server requires but the user doesn't set
// (e.g. project create's parent="/").
if (c.fixed) Object.keys(c.fixed).forEach(function (k) { body[k] = c.fixed[k]; });
submit.disabled = true;
fetch(c.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body)
}).then(function (r) {
return r.text().then(function (t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function (res) {
if (!res.ok) {
submit.disabled = false;
err.textContent = 'Create failed: ' + res.status + ' ' + res.text;
err.hidden = false;
return;
}
close();
var secret = '';
if (c.secretField) {
try { secret = (JSON.parse(res.text) || {})[c.secretField] || ''; } catch (_e) { /* ignore */ }
}
if (secret) showSecret(c.secretLabel || 'New secret (shown once):', secret);
else location.reload();
}).catch(function (e2) {
submit.disabled = false;
err.textContent = 'Create failed: ' + (e2 && e2.message ? e2.message : e2);
err.hidden = false;
});
});
}
function showSecret(label, secret) {
var overlay = el('div', { class: 'api-modal__overlay' });
var modal = el('div', { class: 'api-modal' });
modal.appendChild(el('p', { class: 'api-modal__secret-label' }, label));
var box = el('div', { class: 'api-modal__secret' }, secret);
modal.appendChild(box);
var actions = el('div', { class: 'api-modal__actions' });
var copy = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Copy');
copy.addEventListener('click', function () {
if (navigator.clipboard) navigator.clipboard.writeText(secret);
copy.textContent = 'Copied';
});
var done = el('button', { type: 'button', class: 'btn btn-primary btn-sm' }, 'Done');
done.addEventListener('click', function () { location.reload(); });
actions.appendChild(copy); actions.appendChild(done);
modal.appendChild(actions);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
// ── Per-row delete ──────────────────────────────────────────────────────
function ensureRowDelete(d) {
var tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
var trs = tbody.querySelectorAll('tr');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i];
if (tr.querySelector('.api-revoke')) continue;
var id = tr.getAttribute('data-url');
if (!id) continue;
var cell = tr.lastElementChild;
if (!cell) continue;
var b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm api-revoke' }, d.label || 'Delete');
(function (rowId) {
b.addEventListener('click', function () { revoke(d, rowId); });
})(id);
cell.appendChild(b);
}
}
function revoke(d, id) {
if (d.confirm && !window.confirm(d.confirm)) return;
var url = d.urlTemplate.replace('{id}', encodeURIComponent(id));
fetch(url, { method: 'DELETE', credentials: 'same-origin' }).then(function (r) {
if (r.ok || r.status === 204) location.reload();
else r.text().then(function (t) { window.alert('Delete failed: ' + r.status + ' ' + t); });
}).catch(function (e) { window.alert('Delete failed: ' + (e && e.message ? e.message : e)); });
}
// Suppress the file-model toolbar affordances that don't apply to an API
// collection: native "+ Add row" (posts to the form-create file endpoint)
// and "Save" (flushes dirty row files). Re-run each tick in case main.js
// toggles them after us.
function hideNative() {
// Use inline display:none, not the [hidden] attr — the .btn display
// rule overrides [hidden] and the buttons would stay visible.
['table-add-row', 'table-save'].forEach(function (id) {
var b = document.getElementById(id);
if (b) b.style.display = 'none';
});
}
// Per-row navigation: clicking a row opens its data-url (the project /
// subtree it represents) — used by the profile "Effective access" table.
// Clicks on inner controls (buttons/links/inputs) are left alone.
function ensureRowNav() {
var tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
var trs = tbody.querySelectorAll('tr');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i];
if (tr.getAttribute('data-nav') === '1') continue;
var url = tr.getAttribute('data-url');
if (!url) continue;
tr.setAttribute('data-nav', '1');
tr.style.cursor = 'pointer';
(function (target) {
// Capture phase: fire before the tables editor's per-cell
// click handlers (which would otherwise swallow the click on
// read-only rows). Inner controls (buttons/links/inputs) still
// opt out.
tr.addEventListener('click', function (e) {
if (e.target.closest('button, a, input')) return;
window.location.href = target;
}, true);
})(url);
}
}
function tick() {
if (!active()) return;
hideNative();
var c = cfg();
if (!c) return; // read-only view: native buttons hidden, nothing more
if (c.create) mountCreate(c.create);
if (c.deleteRow) ensureRowDelete(c.deleteRow);
if (c.rowNav) ensureRowNav();
}
function start() {
// app.context is set asynchronously by main.js (await context.load()).
// Poll until it's present, then run once + observe the tbody so the
// per-row buttons survive sort/filter re-renders.
var tries = 0;
var iv = setInterval(function () {
if (active() || tries++ > 60) {
clearInterval(iv);
if (!active()) return;
tick();
var tbody = document.querySelector('#table-root tbody');
if (tbody && window.MutationObserver) {
new MutationObserver(function () { tick(); }).observe(tbody, { childList: true });
}
}
}, 100);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})(window.tablesApp = window.tablesApp || {});