Compare commits
2 commits
224584d379
...
b5747d1104
| Author | SHA1 | Date | |
|---|---|---|---|
| b5747d1104 | |||
| c363549388 |
7 changed files with 227 additions and 9 deletions
|
|
@ -10,7 +10,9 @@ const _configDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const _modalScript = readFileSync(
|
const _modalScript = readFileSync(
|
||||||
join(_configDir, "src/components/improvement-modal.js"), "utf-8"
|
join(_configDir, "src/components/improvement-modal.js"), "utf-8"
|
||||||
)
|
)
|
||||||
.replace(/^export function /gm, "function ") // strip ES module export
|
// Strip ES module exports so the file can run as a plain <script>.
|
||||||
|
.replace(/^export function /gm, "function ")
|
||||||
|
.replace(/^export const /gm, "const ")
|
||||||
+ "\ninitImprovementModal();\n"; // auto-initialise
|
+ "\ninitImprovementModal();\n"; // auto-initialise
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,59 @@
|
||||||
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
|
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
|
||||||
*
|
*
|
||||||
* Interaction:
|
* Interaction:
|
||||||
* - Hold Shift → cursor changes to crosshair across the entire page
|
* - Hold Shift 1s → cursor changes to copy/highlight mode (shift-wait-click)
|
||||||
* - Shift+click any element (except form controls) → opens suggestion modal
|
* - Shift+click any element (except form controls) → opens suggestion modal
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Domain slug for dashboard-improvement TD records (state-hub lives under infotech). */
|
||||||
|
export const DEFAULT_IMPROVEMENT_DOMAIN = "infotech";
|
||||||
|
const DEFAULT_API = "http://127.0.0.1:8000";
|
||||||
|
const API_STORAGE_KEY = "stateHubApiBase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve API base when init is called without an explicit apiBase.
|
||||||
|
* Mirrors dashboard/src/components/config.js so the modal works when the
|
||||||
|
* dashboard is not on 127.0.0.1 (remote host, tunnel, storage override).
|
||||||
|
*/
|
||||||
|
export function resolveImprovementApiBase({
|
||||||
|
location = globalThis.location,
|
||||||
|
storage = globalThis.localStorage,
|
||||||
|
} = {}) {
|
||||||
|
const clean = (value) => {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const cleaned = value.trim().replace(/\/+$/, "");
|
||||||
|
return cleaned || null;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (location?.href) {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
for (const name of ["api_base", "apiBase"]) {
|
||||||
|
const q = clean(url.searchParams.get(name));
|
||||||
|
if (q) return q;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const fromGlobal = clean(globalThis.STATE_HUB_API_BASE);
|
||||||
|
if (fromGlobal) return fromGlobal;
|
||||||
|
try {
|
||||||
|
const fromStorage = clean(storage?.getItem?.(API_STORAGE_KEY));
|
||||||
|
if (fromStorage) return fromStorage;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
try {
|
||||||
|
if (location?.href) {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) return DEFAULT_API;
|
||||||
|
if (url.hostname === "::1" || url.hostname === "[::1]") return DEFAULT_API;
|
||||||
|
url.port = globalThis.STATE_HUB_API_PORT || "8000";
|
||||||
|
url.pathname = "";
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return DEFAULT_API;
|
||||||
|
}
|
||||||
|
|
||||||
const _STYLE_ID = "improvement-modal-styles";
|
const _STYLE_ID = "improvement-modal-styles";
|
||||||
|
|
||||||
function _ensureStyles() {
|
function _ensureStyles() {
|
||||||
|
|
@ -239,10 +288,13 @@ let _initialized = false;
|
||||||
* Safe to call multiple times — only the first call takes effect.
|
* Safe to call multiple times — only the first call takes effect.
|
||||||
*
|
*
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {string} opts.apiBase State Hub API base URL (default: "http://127.0.0.1:8000")
|
* @param {string} [opts.apiBase] State Hub API base URL (resolved like config.js if omitted)
|
||||||
* @param {string} opts.domain Domain slug for the TD record (default: "custodian")
|
* @param {string} [opts.domain] Domain slug for the TD record (default: "infotech")
|
||||||
*/
|
*/
|
||||||
export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain = "custodian" } = {}) {
|
export function initImprovementModal({
|
||||||
|
apiBase = resolveImprovementApiBase(),
|
||||||
|
domain = DEFAULT_IMPROVEMENT_DOMAIN,
|
||||||
|
} = {}) {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
_ensureStyles();
|
_ensureStyles();
|
||||||
|
|
@ -391,7 +443,10 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${apiBase}/technical-debt/`, {
|
const r = await fetch(`${apiBase}/technical-debt/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-StateHub-Component": "state-hub.dashboard.improvement-modal",
|
||||||
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
|
|
@ -400,7 +455,13 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain
|
||||||
} else {
|
} else {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = "Submit suggestion";
|
submitBtn.textContent = "Submit suggestion";
|
||||||
_toast(`⚠ Submission failed (HTTP ${r.status})`);
|
let detail = `HTTP ${r.status}`;
|
||||||
|
try {
|
||||||
|
const body = await r.json();
|
||||||
|
if (typeof body?.detail === "string") detail = body.detail;
|
||||||
|
else if (body?.detail != null) detail = JSON.stringify(body.detail);
|
||||||
|
} catch { /* keep status-only detail */ }
|
||||||
|
_toast(`⚠ Submission failed (${detail})`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
|
|
|
||||||
22
dashboard/src/components/workplan-search.js
Normal file
22
dashboard/src/components/workplan-search.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Case-insensitive substring match for Overview "Workplans by Repository".
|
||||||
|
* Matches title, repo, domain, filename, owner, and id.
|
||||||
|
*
|
||||||
|
* @param {object} workplan Row from overview workplan_rows
|
||||||
|
* @param {string} query Free-text filter
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function workplanMatchesSearch(workplan, query) {
|
||||||
|
const q = String(query ?? "").trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
if (!workplan || typeof workplan !== "object") return false;
|
||||||
|
const haystack = [
|
||||||
|
workplan.title,
|
||||||
|
workplan.repo_label,
|
||||||
|
workplan.domain,
|
||||||
|
workplan.workplan_filename,
|
||||||
|
workplan.owner,
|
||||||
|
workplan.id,
|
||||||
|
].filter(Boolean).join("\n").toLowerCase();
|
||||||
|
return haystack.includes(q);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,13 @@ by repository. Each bar is broken into four task-status segments:
|
||||||
| orange | wait |
|
| orange | wait |
|
||||||
| light grey | todo |
|
| light grey | todo |
|
||||||
|
|
||||||
|
Above the chart:
|
||||||
|
|
||||||
|
- **Mode selector** — lifecycle, health, or recently-changed windows (with
|
||||||
|
matching workplan counts in each option).
|
||||||
|
- **Text filter** — case-insensitive substring match on title, repository,
|
||||||
|
domain, workplan filename, and owner. Useful when many rows share a mode.
|
||||||
|
|
||||||
The left axis shows the `domain / repository` label once per repository group.
|
The left axis shows the `domain / repository` label once per repository group.
|
||||||
The `done/total` count is printed to the right of each bar. Workplans with no
|
The `done/total` count is printed to the right of each bar. Workplans with no
|
||||||
tasks yet show a grey "— no tasks yet" label.
|
tasks yet show a grey "— no tasks yet" label.
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}"
|
||||||
## Workplans by Repository
|
## Workplans by Repository
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
import {workplanMatchesSearch} from "./components/workplan-search.js";
|
||||||
|
|
||||||
// ── Filter workplans by selected mode ───────────────────────────────────────
|
// ── Filter workplans by selected mode ───────────────────────────────────────
|
||||||
// Lifecycle modes match stored canonical status values.
|
// Lifecycle modes match stored canonical status values.
|
||||||
// Health modes are derived labels; they are not stored lifecycle states.
|
// Health modes are derived labels; they are not stored lifecycle states.
|
||||||
|
|
@ -246,6 +248,18 @@ function _setChartMode(value) {
|
||||||
globalThis.__stateHubOverviewChartMode = mode;
|
globalThis.__stateHubOverviewChartMode = mode;
|
||||||
_chartModeState.value = mode;
|
_chartModeState.value = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free-text filter over title / repo / domain / workplan filename (persists in-tab).
|
||||||
|
const _savedChartSearch = typeof globalThis.__stateHubOverviewChartSearch === "string"
|
||||||
|
? globalThis.__stateHubOverviewChartSearch
|
||||||
|
: "";
|
||||||
|
const _chartSearchState = Mutable(_savedChartSearch);
|
||||||
|
|
||||||
|
function _setChartSearch(value) {
|
||||||
|
const q = String(value ?? "");
|
||||||
|
globalThis.__stateHubOverviewChartSearch = q;
|
||||||
|
_chartSearchState.value = q;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|
@ -265,14 +279,32 @@ _modeSelect.addEventListener("input", () => {
|
||||||
_modeSelect.addEventListener("change", () => {
|
_modeSelect.addEventListener("change", () => {
|
||||||
_setChartMode(_modeSelect.value);
|
_setChartMode(_modeSelect.value);
|
||||||
});
|
});
|
||||||
display(_modeSelect);
|
|
||||||
|
const _searchInput = html`<input
|
||||||
|
type="search"
|
||||||
|
class="ws-search-input"
|
||||||
|
placeholder="Filter by title, repo, domain…"
|
||||||
|
aria-label="Filter workplans by text"
|
||||||
|
title="Show only workplans matching this text (title, repository, domain, filename)"
|
||||||
|
/>`;
|
||||||
|
_searchInput.value = String(_chartSearchState ?? "");
|
||||||
|
_searchInput.addEventListener("input", () => {
|
||||||
|
_setChartSearch(_searchInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
display(html`<div class="filter-bar ws-mode-bar" data-widget-name="Workplans by Repository">
|
||||||
|
${_modeSelect}
|
||||||
|
<div class="filter-text-input">${_searchInput}</div>
|
||||||
|
</div>`);
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import * as Plot from "npm:@observablehq/plot";
|
import * as Plot from "npm:@observablehq/plot";
|
||||||
|
|
||||||
const _chartModeValue = _modeValue(_chartModeState);
|
const _chartModeValue = _modeValue(_chartModeState);
|
||||||
const _chartWsFiltered = _workstreamsForMode(_chartModeValue, wsAll);
|
const _chartSearchValue = String(_chartSearchState ?? "");
|
||||||
|
const _chartWsFiltered = _workstreamsForMode(_chartModeValue, wsAll)
|
||||||
|
.filter(w => workplanMatchesSearch(w, _chartSearchValue));
|
||||||
|
|
||||||
// Sort by domain, then repository, then most recently updated workplan.
|
// Sort by domain, then repository, then most recently updated workplan.
|
||||||
// The axis labels show each domain/repo group once.
|
// The axis labels show each domain/repo group once.
|
||||||
|
|
@ -320,6 +352,10 @@ function _wsTitle(d) {
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
if (chartWs.length === 0) {
|
if (chartWs.length === 0) {
|
||||||
|
const _searchQ = _chartSearchValue.trim();
|
||||||
|
if (_searchQ) {
|
||||||
|
display(html`<p style="color:gray">No workplans match “${_searchQ}” for the selected mode.</p>`);
|
||||||
|
} else {
|
||||||
const _emptyMsg = {
|
const _emptyMsg = {
|
||||||
proposed: "No proposed workplans.",
|
proposed: "No proposed workplans.",
|
||||||
ready: "No ready workplans.",
|
ready: "No ready workplans.",
|
||||||
|
|
@ -339,6 +375,7 @@ if (chartWs.length === 0) {
|
||||||
month: "No workplans changed this month.",
|
month: "No workplans changed this month.",
|
||||||
};
|
};
|
||||||
display(html`<p style="color:gray">${_emptyMsg[_chartModeValue] ?? "No workplans."}</p>`);
|
display(html`<p style="color:gray">${_emptyMsg[_chartModeValue] ?? "No workplans."}</p>`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
display(Plot.plot({
|
display(Plot.plot({
|
||||||
y: {
|
y: {
|
||||||
|
|
@ -680,6 +717,7 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
|
||||||
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||||
.ws-mode-bar { margin-bottom: 0.75rem; }
|
.ws-mode-bar { margin-bottom: 0.75rem; }
|
||||||
.ws-mode-select { min-width: 18rem; max-width: 100%; padding: 0.35rem 0.5rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint, #ddd); background: var(--theme-background); color: var(--theme-foreground); font: inherit; }
|
.ws-mode-select { min-width: 18rem; max-width: 100%; padding: 0.35rem 0.5rem; border-radius: 4px; border: 1px solid var(--theme-foreground-faint, #ddd); background: var(--theme-background); color: var(--theme-foreground); font: inherit; }
|
||||||
|
.ws-search-input { min-width: 14rem; max-width: 100%; height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; }
|
||||||
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
||||||
.card.warn { border: 2px solid orange; }
|
.card.warn { border: 2px solid orange; }
|
||||||
.card-link { cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; text-decoration: none; color: inherit; display: block; }
|
.card-link { cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; text-decoration: none; color: inherit; display: block; }
|
||||||
|
|
|
||||||
46
dashboard/test/improvement-modal.test.mjs
Normal file
46
dashboard/test/improvement-modal.test.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {readFileSync} from "node:fs";
|
||||||
|
import {dirname, join} from "node:path";
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_IMPROVEMENT_DOMAIN,
|
||||||
|
resolveImprovementApiBase,
|
||||||
|
} from "../src/components/improvement-modal.js";
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const modalSource = readFileSync(
|
||||||
|
join(root, "../src/components/improvement-modal.js"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("dashboard-improvement default domain is the live infotech slug", () => {
|
||||||
|
assert.equal(DEFAULT_IMPROVEMENT_DOMAIN, "infotech");
|
||||||
|
// Regression: custodian was retired from the domain registry; POST /technical-debt/
|
||||||
|
// returns 422 for unknown domain, so the shift-wait-click modal must not use it.
|
||||||
|
assert.doesNotMatch(modalSource, /domain\s*=\s*["']custodian["']/);
|
||||||
|
assert.match(modalSource, /domain\s*=\s*DEFAULT_IMPROVEMENT_DOMAIN/);
|
||||||
|
assert.match(modalSource, /debt_type:\s*["']dashboard-improvement["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("improvement modal api base mirrors dashboard config resolution", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveImprovementApiBase({location: null, storage: null}),
|
||||||
|
"http://127.0.0.1:8000",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveImprovementApiBase({
|
||||||
|
location: new URL("http://localhost:3000/workstreams"),
|
||||||
|
storage: null,
|
||||||
|
}),
|
||||||
|
"http://localhost:8000",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveImprovementApiBase({
|
||||||
|
location: new URL("http://localhost:3000/?api_base=http%3A%2F%2F127.0.0.1%3A18000%2F"),
|
||||||
|
storage: {getItem: () => "http://ignored.example:8000"},
|
||||||
|
}),
|
||||||
|
"http://127.0.0.1:18000",
|
||||||
|
);
|
||||||
|
});
|
||||||
42
dashboard/test/workplan-search.test.mjs
Normal file
42
dashboard/test/workplan-search.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {readFileSync} from "node:fs";
|
||||||
|
import {dirname, join} from "node:path";
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
|
||||||
|
import {workplanMatchesSearch} from "../src/components/workplan-search.js";
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const indexMd = readFileSync(join(root, "../src/index.md"), "utf-8");
|
||||||
|
|
||||||
|
const sample = {
|
||||||
|
id: "abc-123",
|
||||||
|
title: "Overview Workstream Stage Counts",
|
||||||
|
repo_label: "state-hub",
|
||||||
|
domain: "infotech",
|
||||||
|
workplan_filename: "STATE-WP-0057-overview-workstream-stage-counts.md",
|
||||||
|
owner: "codex",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("empty query matches all workplans", () => {
|
||||||
|
assert.equal(workplanMatchesSearch(sample, ""), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, " "), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, null), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches title, repo, domain, filename, owner, and id", () => {
|
||||||
|
assert.equal(workplanMatchesSearch(sample, "stage counts"), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, "STATE-HUB"), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, "Infotech"), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, "wp-0057"), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, "codex"), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, "abc-123"), true);
|
||||||
|
assert.equal(workplanMatchesSearch(sample, "railiance"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overview chart wires the text filter into Workplans by Repository", () => {
|
||||||
|
assert.match(indexMd, /workplanMatchesSearch/);
|
||||||
|
assert.match(indexMd, /ws-search-input/);
|
||||||
|
assert.match(indexMd, /Filter by title, repo, domain/);
|
||||||
|
assert.match(indexMd, /## Workplans by Repository/);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue