feat(dashboard): edit in place over the live dashboard with a bottom widget drawer
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m16s
CI / publish (push) Successful in 58s

Milestone 72 phase D — the dashboard view IS the edit surface (operator ask):
- /d/<id> renders the grid via Gridstack in STATIC mode — positioned exactly like
  before, live HTMX widget bodies keep polling. An "Edit" button flips the same
  grid interactive (grid.setStatic(false)) in place, so you drag/resize over real
  data. "Done" flips it back. Layout autosaves on change.
- The widget picker is now a bottom drawer (position:fixed overlay) revealed by
  body.dash-editing — so the dashboard width is identical entering/leaving edit.
- Add: POST returns a single grid item; JS inserts it + grid.makeWidget +
  htmx.process so it loads live data. Remove: POST 204 + grid.removeWidget.
  Per-panel drag handle + remove ✕ are in the DOM for editors, shown only while
  editing.
- Gridstack loads for everyone (viewers get the static grid); edit wiring is
  gated on can_edit. Mobile collapses to one column.
- Removed the separate /d/<id>/edit route + edit.html + _edit_panels.html
  (rule 22). Dashboard-list Edit and new-dashboard create now deep-link
  /d/<id>?edit=1 which opens edit mode on load.

Browser-only behaviour — CI can't exercise it; needs an operator visual check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 15:42:10 -04:00
parent 988c13d51f
commit 88dca32d3c
8 changed files with 257 additions and 270 deletions
+99 -23
View File
@@ -1,5 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ dashboard.name }} — Steward{% endblock %}
{% block head %}
{# Gridstack renders the grid for everyone (static for viewers); editors also get
the in-place drag-resize + bottom drawer. #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/gridstack@12.6.0/dist/gridstack.min.css">
{% endblock %}
{% block content %}
{# ── Header ────────────────────────────────────────────────────────────────── #}
@@ -21,7 +26,7 @@
</select>
{% endif %}
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
<button type="button" id="dash-edit-toggle" class="btn btn-ghost btn-sm">Edit</button>
<a href="/d/{{ dashboard.id }}/share" class="btn btn-ghost btn-sm">Share</a>
{% endif %}
<a href="/dashboards/" class="btn btn-ghost btn-sm">Dashboards</a>
@@ -60,31 +65,102 @@
</div>
</div>
{# ── Widget grid ───────────────────────────────────────────────────────────── #}
{% if widgets %}
<div class="dash-grid">
{% for item in widgets %}
{% set gw = item.db %}
<div class="card dash-cell"
style="grid-column:{{ gw.grid_x + 1 }} / span {{ gw.grid_w }};grid-row:{{ gw.grid_y + 1 }} / span {{ gw.grid_h }};">
<div class="widget-header">
<span class="widget-title" title="{{ item.title }}">{{ item.title }}</span>
<a href="{{ item.defn.detail_url }}" class="widget-link">Details →</a>
</div>
<div class="dash-cell-body" id="widget-{{ gw.id }}"
hx-get="{{ item.hx_url }}"
hx-trigger="load{% if item.defn.poll %}, every {{ poll_interval }}s{% endif %}"
hx-swap="innerHTML">
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
{# ── Widget grid (Gridstack; static until Edit is toggled) ──────────────────── #}
{% if not widgets %}
<div id="dash-empty" class="card" style="text-align:center;padding:3rem;">
<div style="font-size:1.1rem;color:var(--text-muted);margin-bottom:1rem;">No widgets on this dashboard yet.</div>
{% if can_edit %}
<a href="/d/{{ dashboard.id }}/edit" class="btn">Add Widgets</a>
<button type="button" class="btn" onclick="document.getElementById('dash-edit-toggle').click()">Add Widgets</button>
{% endif %}
</div>
{% endif %}
<div class="grid-stack" data-dash-id="{{ dashboard.id }}" data-can-edit="{{ '1' if can_edit else '0' }}">
{% for item in widgets %}
{% include "dashboard/_grid_item.html" %}
{% endfor %}
</div>
{% if can_edit %}{% include "dashboard/_widget_drawer.html" %}{% endif %}
{% endblock %}
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/gridstack@12.6.0/dist/gridstack-all.min.js"></script>
<script>
(function () {
var gridEl = document.querySelector('.grid-stack');
if (!gridEl || typeof GridStack === 'undefined') return;
var dashId = gridEl.dataset.dashId;
var canEdit = gridEl.dataset.canEdit === '1';
var editing = false;
var grid = GridStack.init({
column: 12, cellHeight: 70, margin: 8, float: false,
staticGrid: true, // view mode: positioned but inert
handle: '.panel-drag',
resizable: { handles: 'e, se, s' },
columnOpts: { breakpointForWindow: true, breakpoints: [{ w: 700, c: 1 }] },
}, gridEl);
if (!canEdit) return; // viewers get the static grid only
function saveLayout() {
var items = grid.save(false).map(function (n) {
return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h };
});
fetch('/d/' + dashId + '/edit/layout', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: items }),
});
}
grid.on('change', function () { if (editing) saveLayout(); });
function setEditing(on) {
editing = on;
grid.setStatic(!on);
document.body.classList.toggle('dash-editing', on);
var btn = document.getElementById('dash-edit-toggle');
if (btn) btn.textContent = on ? 'Done' : 'Edit';
}
var toggle = document.getElementById('dash-edit-toggle');
if (toggle) toggle.addEventListener('click', function () { setEditing(!editing); });
var done = document.getElementById('dash-edit-done');
if (done) done.addEventListener('click', function () { setEditing(false); });
// Add widget — POST the form, insert the returned grid item in place.
document.querySelectorAll('.add-widget-form').forEach(function (form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
fetch(form.action, { method: 'POST', body: new FormData(form) })
.then(function (r) { return r.status === 204 ? '' : r.text(); })
.then(function (html) {
if (!html) return;
var tmp = document.createElement('div');
tmp.innerHTML = html.trim();
var el = tmp.firstElementChild;
if (!el) return;
gridEl.appendChild(el);
grid.makeWidget(el);
if (window.htmx) window.htmx.process(el); // load its live data
var empty = document.getElementById('dash-empty');
if (empty) empty.style.display = 'none';
var det = form.closest('details');
if (det) det.open = false;
});
});
});
// Remove widget — delegated; POST then drop the grid item.
gridEl.addEventListener('click', function (ev) {
var rm = ev.target.closest('[data-remove]');
if (!rm) return;
ev.preventDefault();
var item = rm.closest('.grid-stack-item');
fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove, { method: 'POST' })
.then(function () { if (item) grid.removeWidget(item); });
});
// Deep link: /d/<id>?edit=1 opens straight into edit mode (new dashboards do this).
if (new URLSearchParams(window.location.search).get('edit') === '1') setEditing(true);
})();
</script>
{% endblock %}