feat(dashboard): edit in place over the live dashboard with a bottom widget drawer
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:
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user