Skip to content

Commit 86eef96

Browse files
feat(ops): kebab action column + 3-item menu (A3b)
A3b of docs/specs/ops-workflows-page-refinement/. Adds the row-level action menu per decisions.md D5. Distinct from the Specs page kebab — Workflows kebab is about run-management, not spec-metadata navigation. The 3 actions: 1. **View recent runs** — toggles the existing per-row .recent-runs strip (the existing affordance was hidden by default and only opened after the first run; this exposes it without needing to run anything first). 2. **Copy run command** — writes `attune workflow run <name>` to the clipboard. If the row's scope picker has a selected value (or custom-path input has text), appends ` --path <scope>`. Toast confirmation on success. 3. **View docs** — opens `/help/<workflow-name>` in a new tab (the per-workflow help page from the ops-help-page spec). Fallback handled by the /help route itself. Files: - src/attune/ops/templates/workflows.html — kebab column header (no allow_run gate — all 3 actions are non-mutating; works in read-only mode) + per-row kebab button trigger. - src/attune/ops/static/js/workflows_kebab.js — menu open/close, 3 action handlers, toast (lazily-injected container), keyboard Escape support, outside-click closure, single-menu-at-a-time enforcement. Reuses existing global .kebab-btn / .kebab-menu / .specs-toast CSS classes shipped with Specs page A3b. - src/attune/ops/static/css/main.css — adds .workflows-table .col-kebab to the existing rule (one line — same width/align as Specs page). Tests (24 new): tests/unit/ops/test_workflows_kebab_js.py — source-grep tests mirroring the test_workflows_refined_js.py + test_specs_routes.py convention. Coverage: - window.__attuneWorkflowsKebab namespace - 5 documented exports (parameterized) - 3 actions present (recent / copy / docs) with menu labels - Action implementations recognizable (data-recent-runs, "attune workflow run", --path + .scope-picker, "/help/", _blank + noopener) - Menu lifecycle (Escape, outside-click, single-menu) - DOM selectors (.kebab-btn[data-kebab]) - Template integration (loads workflows_kebab.js, col-kebab header rendered, per-row kebab button) - Read-only mode safety: col-kebab is rendered OUTSIDE the {% if allow_run %} conditional CodeQL-safe URL substring assertions: "/help/" with quotes and "_blank"/"noopener" anchored on the path fragment, dodging the py/incomplete-url-substring-sanitization rule per the existing CLAUDE.md lesson. Stacks on A3a (#554). Next: A3c (URL param parsing — final A3). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 31afa40 commit 86eef96

4 files changed

Lines changed: 482 additions & 1 deletion

File tree

src/attune/ops/static/css/main.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2611,7 +2611,8 @@ a.kpi:hover {
26112611
style set in JS.
26122612
============================================================ */
26132613

2614-
.specs-table .col-kebab {
2614+
.specs-table .col-kebab,
2615+
.workflows-table .col-kebab {
26152616
text-align: right;
26162617
width: 32px;
26172618
white-space: nowrap;
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// Workflows page — kebab action menu (A3b).
2+
//
3+
// Each row has a ⋯ button that opens a 3-item menu per decisions.md D5:
4+
// - View recent runs → toggles the per-row .recent-runs strip
5+
// - Copy run command → `attune workflow run <name>` (+ --path <scope>
6+
// when the row's scope picker has a value)
7+
// - View docs → /help/<workflow-name> in new tab (falls back
8+
// to /help index if a per-workflow help page
9+
// doesn't exist yet)
10+
//
11+
// Distinct from the Specs page kebab (which is about spec-metadata
12+
// navigation): Workflows kebab is about run-management.
13+
//
14+
// Menu floats positioned via fixed coordinates anchored to the kebab
15+
// cell, appended to <body> so it escapes table overflow. Closes on:
16+
// outside click, Escape, item select.
17+
//
18+
// Read-only mode safe: all 3 actions are non-mutating. No allow_run
19+
// gating needed.
20+
//
21+
// Exports window.__attuneWorkflowsKebab for source-grep tests +
22+
// future PR hooks.
23+
24+
(function () {
25+
"use strict";
26+
27+
// ---------------------------------------------------------------
28+
// State
29+
// ---------------------------------------------------------------
30+
31+
var openMenu = null; // current open <div.kebab-menu> or null
32+
var openTrigger = null; // the kebab button that opened it
33+
var toastTimer = null;
34+
35+
// ---------------------------------------------------------------
36+
// Menu open/close
37+
// ---------------------------------------------------------------
38+
39+
function closeMenu() {
40+
if (openMenu && openMenu.parentNode) {
41+
openMenu.parentNode.removeChild(openMenu);
42+
}
43+
if (openTrigger) {
44+
openTrigger.setAttribute("aria-expanded", "false");
45+
}
46+
openMenu = null;
47+
openTrigger = null;
48+
}
49+
50+
function openMenuFor(btn) {
51+
closeMenu();
52+
var tr = btn.closest("tr");
53+
if (!tr) return;
54+
var workflowName = btn.getAttribute("data-kebab") || "";
55+
56+
var menu = document.createElement("div");
57+
menu.className = "kebab-menu";
58+
menu.setAttribute("role", "menu");
59+
menu.setAttribute("aria-label", "Actions for " + workflowName);
60+
61+
menu.innerHTML = [
62+
'<button type="button" role="menuitem" class="kebab-menu-item" ' +
63+
'data-action="recent">View recent runs</button>',
64+
'<button type="button" role="menuitem" class="kebab-menu-item" ' +
65+
'data-action="copy">Copy run command</button>',
66+
'<button type="button" role="menuitem" class="kebab-menu-item" ' +
67+
'data-action="docs">View docs ' +
68+
'<span class="kebab-external-glyph" aria-hidden="true">↗</span>' +
69+
"</button>",
70+
].join("");
71+
72+
document.body.appendChild(menu);
73+
74+
// Position: below + right-aligned to the kebab cell.
75+
var rect = btn.getBoundingClientRect();
76+
var menuRect = menu.getBoundingClientRect();
77+
menu.style.top = rect.bottom + window.scrollY + 4 + "px";
78+
menu.style.left = rect.right + window.scrollX - menuRect.width + "px";
79+
80+
// Wire item handlers.
81+
var items = menu.querySelectorAll(".kebab-menu-item");
82+
items.forEach(function (item) {
83+
item.addEventListener("click", function (ev) {
84+
ev.stopPropagation();
85+
if (item.hasAttribute("disabled")) return;
86+
var action = item.getAttribute("data-action");
87+
handleAction(action, workflowName, tr);
88+
closeMenu();
89+
});
90+
});
91+
92+
btn.setAttribute("aria-expanded", "true");
93+
openMenu = menu;
94+
openTrigger = btn;
95+
96+
var firstEnabled = menu.querySelector(".kebab-menu-item:not([disabled])");
97+
if (firstEnabled) firstEnabled.focus();
98+
}
99+
100+
// ---------------------------------------------------------------
101+
// Actions
102+
// ---------------------------------------------------------------
103+
104+
function handleAction(action, workflowName, tr) {
105+
if (action === "recent") {
106+
// Toggle the existing per-row recent-runs strip. It's hidden
107+
// by default; runner.js populates it after the first run.
108+
var strip = tr.querySelector(
109+
'[data-recent-runs="' + cssEscape(workflowName) + '"]'
110+
);
111+
if (!strip) {
112+
showToast("No recent-runs strip on this row.");
113+
return;
114+
}
115+
strip.hidden = !strip.hidden;
116+
return;
117+
}
118+
if (action === "copy") {
119+
// Build the CLI command. If the row has a scope picker with a
120+
// non-empty value, include --path. Custom-path inputs (the
121+
// "Custom path…" option opens an input) aren't included unless
122+
// they have an actual value.
123+
var cmd = "attune workflow run " + workflowName;
124+
var picker = tr.querySelector(".scope-picker");
125+
var customInput = tr.querySelector(".scope-custom");
126+
var scope = "";
127+
if (
128+
customInput &&
129+
!customInput.hidden &&
130+
customInput.value &&
131+
customInput.value.trim()
132+
) {
133+
scope = customInput.value.trim();
134+
} else if (picker && picker.value && picker.value !== "__custom__") {
135+
scope = picker.value;
136+
}
137+
if (scope) {
138+
cmd += " --path " + scope;
139+
}
140+
copyToClipboard(cmd).then(
141+
function () {
142+
showToast("Copied: " + cmd);
143+
},
144+
function () {
145+
showToast("Copy failed — clipboard unavailable.");
146+
}
147+
);
148+
return;
149+
}
150+
if (action === "docs") {
151+
// /help/<workflow-name> is the per-workflow help page (added in
152+
// the ops-help-page spec, PR #482-#484). If a per-workflow page
153+
// doesn't exist, the help route falls back to the index — no
154+
// 404. Open in a new tab to preserve the user's place on the
155+
// workflows list.
156+
var url = "/help/" + encodeURIComponent(workflowName);
157+
window.open(url, "_blank", "noopener,noreferrer");
158+
return;
159+
}
160+
}
161+
162+
function copyToClipboard(text) {
163+
if (navigator.clipboard && navigator.clipboard.writeText) {
164+
return navigator.clipboard.writeText(text);
165+
}
166+
return new Promise(function (resolve, reject) {
167+
try {
168+
var ta = document.createElement("textarea");
169+
ta.value = text;
170+
ta.style.position = "fixed";
171+
ta.style.left = "-9999px";
172+
document.body.appendChild(ta);
173+
ta.select();
174+
var ok = document.execCommand && document.execCommand("copy");
175+
document.body.removeChild(ta);
176+
if (ok) resolve();
177+
else reject(new Error("execCommand failed"));
178+
} catch (e) {
179+
reject(e);
180+
}
181+
});
182+
}
183+
184+
function cssEscape(s) {
185+
// Minimal CSS.escape polyfill for the attribute selector. Workflow
186+
// names are alphanumeric + hyphens so this is mostly a no-op,
187+
// but if CSS.escape is available use it for safety.
188+
if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(s);
189+
return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
190+
}
191+
192+
// ---------------------------------------------------------------
193+
// Toast feedback
194+
// ---------------------------------------------------------------
195+
196+
function showToast(msg) {
197+
var t = document.getElementById("workflows-toast");
198+
if (!t) {
199+
// Inject a toast container lazily — same pattern as Specs page
200+
// but the workflows template doesn't include one by default.
201+
t = document.createElement("div");
202+
t.id = "workflows-toast";
203+
t.className = "specs-toast"; // reuse the styling
204+
t.setAttribute("role", "status");
205+
t.setAttribute("aria-live", "polite");
206+
document.body.appendChild(t);
207+
}
208+
t.textContent = msg;
209+
t.classList.add("toast-visible");
210+
if (toastTimer) clearTimeout(toastTimer);
211+
toastTimer = setTimeout(function () {
212+
t.classList.remove("toast-visible");
213+
}, 2200);
214+
}
215+
216+
// ---------------------------------------------------------------
217+
// Wiring
218+
// ---------------------------------------------------------------
219+
220+
function wireKebabButtons() {
221+
document.querySelectorAll(".kebab-btn[data-kebab]").forEach(function (btn) {
222+
btn.addEventListener("click", function (ev) {
223+
ev.stopPropagation();
224+
if (openTrigger === btn) {
225+
closeMenu();
226+
} else {
227+
openMenuFor(btn);
228+
}
229+
});
230+
});
231+
}
232+
233+
function wireGlobalClose() {
234+
document.addEventListener("click", function (ev) {
235+
if (!openMenu) return;
236+
if (openMenu.contains(ev.target)) return;
237+
if (openTrigger && openTrigger.contains(ev.target)) return;
238+
closeMenu();
239+
});
240+
document.addEventListener("keydown", function (ev) {
241+
if (ev.key === "Escape" && openMenu) {
242+
var t = openTrigger;
243+
closeMenu();
244+
if (t) t.focus();
245+
}
246+
});
247+
}
248+
249+
function init() {
250+
wireKebabButtons();
251+
wireGlobalClose();
252+
}
253+
254+
if (document.readyState === "loading") {
255+
document.addEventListener("DOMContentLoaded", init);
256+
} else {
257+
init();
258+
}
259+
260+
// Exports for future PR hooks + source-grep tests.
261+
window.__attuneWorkflowsKebab = {
262+
openMenuFor: openMenuFor,
263+
closeMenu: closeMenu,
264+
handleAction: handleAction,
265+
showToast: showToast,
266+
init: init,
267+
};
268+
})();

src/attune/ops/templates/workflows.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ <h2 class="bulletin-strip-title">
6363
<th>Description</th>
6464
<th>Scope</th>
6565
{% if allow_run %}<th class="num">Action</th>{% endif %}
66+
{# A3b — kebab column for the action menu. Always rendered
67+
(no allow_run gate) since all 3 actions are non-mutating. #}
68+
<th class="col-kebab" aria-label="Row actions"></th>
6669
</tr>
6770
</thead>
6871
<tbody>
@@ -176,6 +179,19 @@ <h2 class="bulletin-strip-title">
176179
<button class="btn btn-run" type="button" data-run-button data-workflow="{{ w.name }}" aria-label="Run {{ w.name }}">Run</button>
177180
</td>
178181
{% endif %}
182+
{# A3b — kebab trigger. workflows_kebab.js injects the menu
183+
on click; menu floats positioned via fixed coords, appended
184+
to <body> so it escapes table overflow. #}
185+
<td class="col-kebab kebab-cell">
186+
<button
187+
type="button"
188+
class="kebab-btn"
189+
data-kebab="{{ w.name }}"
190+
aria-label="Actions for {{ w.name }}"
191+
aria-haspopup="menu"
192+
aria-expanded="false"
193+
></button>
194+
</td>
179195
</tr>
180196
{% endfor %}
181197
</tbody>
@@ -211,5 +227,9 @@ <h2 class="bulletin-strip-title">
211227
bulletin.js still load unconditionally above. #}
212228
{% if workflows %}
213229
<script src="{{ url_for('static', path='js/workflows_refined.js') }}?v={{ attune_version }}"></script>
230+
{# A3b — workflows_kebab.js handles the row-level action menu
231+
(View recent runs / Copy run command / View docs). Pure DOM,
232+
no fetch. Toast container is lazily injected by the script. #}
233+
<script src="{{ url_for('static', path='js/workflows_kebab.js') }}?v={{ attune_version }}"></script>
214234
{% endif %}
215235
{% endblock %}

0 commit comments

Comments
 (0)