Public: anyone can read this; the crew can change it.
/next narrows to one pillar or a word: server-rendered filters that keep the owner's order #11887
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11867-next-narrows-to-one-pillar-or-a-word-ser → main 7 commits fork 3 threads · 3 unresolved
7 files
changed +319
−30
docs/features/next-up.md +2
| @@ -13,6 +13,8 @@ | ||
| 13 | 13 | |
| 14 | 14 | **Terminal:** the palette's "Next up in a repository" opens the same list; `t`, `u`, `d` and `n` are Top, Up, Down and Not now on the row under the cursor, through the same action as the page and MCP. A reader sees the order and the keys do nothing; a move made anywhere reorders an open screen live. At most 200 issues are ranked; a move past that is refused and says so. |
| 15 | 15 | |
| 16 | **Filter (#11867):** a plain GET form above the list narrows `/next` to one pillar (`?pillar=P7` or `Later`, typed in a box) and/or a word (`?q=cache`, matched without regard to case, shown as typed, at most 80 characters). A filter only hides rows: each keeps its real place in the whole ranking, the cut line stays where the whole list puts it (drawn only between shown rows), and a move made under a filter moves in the whole order and returns to the same filter. A line says "N of M shown", and that the week's capacity bar is left out while filtered. An unknown or malformed pillar shows an empty list that says so. **Terminal:** `/` asks for a pillar and/or a word (`P7 cache`); an empty answer clears it; ranks, `t` `u` `d` `n` and the header count behave as on the page. | |
| 17 | ||
| 16 | 18 | ## The week is the sprint (#8704) |
| 17 | 19 | |
| 18 | 20 | The subscription's weekly reset is the sprint boundary; what the crew may still spend before it is the capacity (`flow/week.ts`, from `core/standing.ts`). No ceremony and no locked scope: rerank any time and the line moves on the next paint. |
src/tui/screens/next.ts +58 −13
| @@ -14,11 +14,13 @@ | ||
| 14 | 14 | import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actions/next.ts"; |
| 15 | 15 | import { nextUp, type NextRow } from "../../core/next_up.ts"; |
| 16 | 16 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; |
| 17 | import { pillarsOf, servedBy } from "../../core/pillars.ts"; | |
| 17 | 18 | import { sealedScope } from "../../core/sealed.ts"; |
| 18 | 19 | import { notStartable } from "../../crew/not_startable.ts"; |
| 19 | 20 | import { rangeWords, weekView, type WeekView } from "../../flow/week.ts"; |
| 20 | 21 | import { openPullStages } from "../../flow/read.ts"; |
| 21 | 22 | import type { Stage } from "../../flow/stage.ts"; |
| 23 | import { countWords, filtered, matches, readFilter, MAX_Q } from "../../web/pages/next_filter.ts"; | |
| 22 | 24 | import { stateWord } from "../../web/pages/next_state.ts"; |
| 23 | 25 | import type { Ctx, Screen } from "../app/app.ts"; |
| 24 | 26 | import { binding } from "../app/keys.ts"; |
| @@ -28,7 +30,7 @@ import { truncate } from "../term/width.ts"; | ||
| 28 | 30 | import type { Session } from "../session.ts"; |
| 29 | 31 | import { issueScreen } from "./issue.ts"; |
| 30 | 32 | import { local } from "./where.ts"; |
| 31 | import { ListState, drawList, hitList, live, type ListRow } from "./common.ts"; | |
| 33 | import { ListState, drawList, hitList, live, promptScreen, type ListRow } from "./common.ts"; | |
| 32 | 34 | |
| 33 | 35 | /** What reorders or reshapes the list: a rank, or an issue opening, closing or changing what it waits for. */ |
| 34 | 36 | const LIVE_KINDS = ["issue.ranked", "issue.opened", "issue.closed", "issue.reopened", "issue.blocked", "issue.unblocked"]; |
| @@ -40,6 +42,10 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 40 | 42 | let week: WeekView | undefined; |
| 41 | 43 | let stages = new Map<number, Stage>(); |
| 42 | 44 | let loaded = false; |
| 45 | // The filter (#11867): a pillar (`P7`, `Later`) and/or words, as on the page. It hides rows | |
| 46 | // only; ranks and moves stay the whole list's. | |
| 47 | let asked = ""; | |
| 48 | let shown: number[] = []; | |
| 43 | 49 | // Read at use, not at open: access can change under a session. |
| 44 | 50 | const may = (): boolean => |
| 45 | 51 | f.user !== undefined && f.user.kind === "user" && canWrite(f.db, repo, f.user); |
| @@ -58,15 +64,49 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 58 | 64 | stages = openPullStages(f.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number))); |
| 59 | 65 | } |
| 60 | 66 | loaded = true; |
| 67 | narrow(); | |
| 61 | 68 | if (follow !== undefined) { |
| 62 | const at = rows.findIndex((r) => r.issue.number === follow); | |
| 69 | const at = shown.findIndex((i) => rows[i]!.issue.number === follow); | |
| 63 | 70 | if (at >= 0) state.index = at; |
| 64 | 71 | } |
| 65 | state.clamp(rows.length); | |
| 72 | state.clamp(shown.length); | |
| 66 | 73 | ctx.invalidate(); |
| 67 | 74 | }; |
| 68 | 75 | |
| 69 | const current = (): NextRow | undefined => rows[state.index]; | |
| 76 | const filterOf = () => { | |
| 77 | const params = new URLSearchParams(); | |
| 78 | const words = asked.split(/\s+/).filter((w) => w !== ""); | |
| 79 | const pillar = words.find((w) => /^(P[0-9]{1,6}|Later)$/.test(w)); | |
| 80 | if (pillar !== undefined) params.set("pillar", pillar); | |
| 81 | params.set("q", words.filter((w) => w !== pillar).join(" ")); | |
| 82 | return readFilter(params); | |
| 83 | }; | |
| 84 | const narrow = (): void => { | |
| 85 | const filter = filterOf(); | |
| 86 | const on = filtered(filter); | |
| 87 | const served = filter.pillar !== null ? servedBy(f.db, pillarsOf(f.db, repo)) : new Map<string, string>(); | |
| 88 | shown = rows.map((_, at) => at).filter((at) => !on || matches(filter, rows[at]!.issue.title, served.get(rows[at]!.issue.id))); | |
| 89 | }; | |
| 90 | const ask = (): void => { | |
| 91 | ctx.push( | |
| 92 | promptScreen(ctx, screen, { | |
| 93 | title: "Filter", | |
| 94 | label: "A pillar (P7, Later) and/or a word in the title; empty clears", | |
| 95 | value: asked, | |
| 96 | onSubmit: (raw) => { | |
| 97 | if (raw.trim().length > MAX_Q) return "too long"; | |
| 98 | asked = raw.trim(); | |
| 99 | narrow(); | |
| 100 | state.index = 0; | |
| 101 | state.clamp(shown.length); | |
| 102 | ctx.invalidate(); | |
| 103 | return undefined; | |
| 104 | }, | |
| 105 | }), | |
| 106 | ); | |
| 107 | }; | |
| 108 | ||
| 109 | const current = (): NextRow | undefined => rows[shown[state.index] ?? -1]; | |
| 70 | 110 | |
| 71 | 111 | const rank = (to: Place | undefined): void => { |
| 72 | 112 | const row = current(); |
| @@ -80,7 +120,9 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 80 | 120 | ctx.toast(rankedWords(row.issue.number, to), "success"); |
| 81 | 121 | }; |
| 82 | 122 | const step = (by: -1 | 1): void => { |
| 83 | const to = stepTo(rows.map((r) => r.issue.number), state.index, by); | |
| 123 | const row = current(); | |
| 124 | if (row === undefined) return; | |
| 125 | const to = stepTo(rows.map((r) => r.issue.number), rows.indexOf(row), by); | |
| 84 | 126 | rank(to === undefined ? undefined : placeOf(to)); |
| 85 | 127 | }; |
| 86 | 128 | |
| @@ -91,10 +133,11 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 91 | 133 | |
| 92 | 134 | // The cut line is a muted line under the last row that fits, as on the page. |
| 93 | 135 | const listRows = (): ListRow[] => |
| 94 | rows.map((r, at) => { | |
| 136 | shown.map((at, i) => { | |
| 137 | const r = rows[at]!; | |
| 95 | 138 | const estimate = week?.priced.find((p) => p.number === r.issue.number)?.estimate; |
| 96 | 139 | const cut = week?.cut; |
| 97 | const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at === cut.fits.length - 1; | |
| 140 | const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at < cut.fits.length && (shown[i + 1] ?? cut.fits.length) >= cut.fits.length; | |
| 98 | 141 | return { |
| 99 | 142 | title: `${at + 1}. #${r.issue.number} ${r.issue.title}`, |
| 100 | 143 | secondary: `${r.byOwner ? "Owner's pick · " : ""}${r.role === "blocker" ? "Blocker" : "Enabler"}`, |
| @@ -112,10 +155,11 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 112 | 155 | return [ |
| 113 | 156 | binding(["j", "k"], "move cursor"), |
| 114 | 157 | binding(["enter"], "open"), |
| 115 | binding(["t"], "top", { disabled: !on || state.index === 0 }), | |
| 116 | binding(["u"], "up", { disabled: !on || state.index === 0 }), | |
| 117 | binding(["d"], "down", { disabled: !on || state.index >= rows.length - 1 }), | |
| 158 | binding(["t"], "top", { disabled: !on || rows.indexOf(current()!) === 0 }), | |
| 159 | binding(["u"], "up", { disabled: !on || rows.indexOf(current()!) === 0 }), | |
| 160 | binding(["d"], "down", { disabled: !on || rows.indexOf(current()!) >= rows.length - 1 }), | |
| 118 | 161 | binding(["n"], "not now", { disabled: !on }), |
| 162 | binding(["/"], "filter"), | |
| 119 | 163 | ]; |
| 120 | 164 | }, |
| 121 | 165 | load(signal) { |
| @@ -130,18 +174,19 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 130 | 174 | ? "Loading…" |
| 131 | 175 | : rows.length === 0 |
| 132 | 176 | ? "Nothing is ready: everything open waits for something." |
| 133 | : `${week?.sentence ?? ""}${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`; | |
| 177 | : `${asked !== "" ? `${countWords(shown.length, rows.length)} shown for "${asked}" · ` : ""}${week?.sentence ?? ""}${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`; | |
| 134 | 178 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 135 | 179 | drawList(s, list!, c, state, listRows()); |
| 136 | hitList(ctx, list!, state, rows.length, { open }); | |
| 180 | hitList(ctx, list!, state, shown.length, { open }); | |
| 137 | 181 | }, |
| 138 | 182 | key(e) { |
| 139 | if (state.key(e, rows.length)) return true; | |
| 183 | if (state.key(e, shown.length)) return true; | |
| 140 | 184 | if (e.ctrl || e.alt) return false; |
| 141 | 185 | if (e.key === "enter") open(); |
| 142 | 186 | else if (e.key === "t") rank({ to: "top" }); |
| 143 | 187 | else if (e.key === "u") step(-1); |
| 144 | 188 | else if (e.key === "d") step(1); |
| 189 | else if (e.key === "/") ask(); | |
| 145 | 190 | else if (e.key === "n") rank({ to: "not_now" }); |
| 146 | 191 | else return false; |
| 147 | 192 | return true; |
src/web/pages/next.ts +43 −16
| @@ -12,6 +12,7 @@ import { openPullStages } from "../../flow/read.ts"; | ||
| 12 | 12 | import { stateWord, type StateFacts } from "./next_state.ts"; |
| 13 | 13 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 14 | 14 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 15 | import { pillarsOf, servedBy } from "../../core/pillars.ts"; | |
| 15 | 16 | import { sealedScope } from "../../core/sealed.ts"; |
| 16 | 17 | import { notStartable } from "../../crew/not_startable.ts"; |
| 17 | 18 | import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts"; |
| @@ -21,6 +22,7 @@ import { backTo, field, readForm } from "../form.ts"; | ||
| 21 | 22 | import { html, raw, type Html } from "../html.ts"; |
| 22 | 23 | import { flashOrJson, page } from "../layout.ts"; |
| 23 | 24 | import { btn, kbd, row } from "../ui.ts"; |
| 25 | import { countWords, filterPath, markBefore, filtered, matches, readFilter } from "./next_filter.ts"; | |
| 24 | 26 | |
| 25 | 27 | /** |
| 26 | 28 | * The owner's issue titles are their own words, so inside `[data-next]` a long |
| @@ -38,7 +40,9 @@ export const NEXT_CSS = `[data-next] { overflow-wrap: anywhere; } | ||
| 38 | 40 | [data-next] .row-after { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; padding: 0 14px 8px; } |
| 39 | 41 | [data-next] summary h2 { display: inline; margin: 0; } |
| 40 | 42 | [data-next] .mine { font-weight: 600; } |
| 41 | [data-next] .cut-line { overflow-wrap: anywhere; }`; | |
| 43 | [data-next] .cut-line { overflow-wrap: anywhere; } | |
| 44 | [data-next] .form-actions > .input { flex: 1 1 10rem; min-width: 0; min-height: 44px; } | |
| 45 | [data-next] .form-actions > .btn, [data-next] .form-actions > a { min-height: 44px; display: inline-flex; align-items: center; }`; | |
| 42 | 46 | |
| 43 | 47 | export const KEYS_JS = ` |
| 44 | 48 | (() => { |
| @@ -194,37 +198,60 @@ export function nextPage(ctx: Ctx): Response { | ||
| 194 | 198 | const scope = sealedScope(ctx.app.db, repo, ctx.auth.user); |
| 195 | 199 | const next = nextUp(ctx.app.db, repo, scope, (ready) => notStartable(ctx.app, repo, ready, scope)); |
| 196 | 200 | const rows = next.rows; |
| 197 | const back = `${base}/next`; | |
| 201 | const filter = readFilter(ctx.url.searchParams); | |
| 202 | const isFiltered = filtered(filter); | |
| 203 | const back = filterPath(`${base}/next`, filter, ctx.url.searchParams.get("pillar") ?? ""); | |
| 204 | // Pillars are read only when a pillar is asked for: the unfiltered page stays at its budget. | |
| 205 | const pillars = filter.pillar !== null ? pillarsOf(ctx.app.db, repo) : undefined; | |
| 206 | const served = pillars !== undefined ? servedBy(ctx.app.db, pillars) : new Map<string, string>(); | |
| 207 | const shows = (r: NextRow): boolean => !isFiltered || matches(filter, r.issue.title, served.get(r.issue.id)); | |
| 198 | 208 | const rendering = performance.now(); |
| 199 | 209 | const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, rows, new Date()); |
| 200 | 210 | const costs = rowCosts(priced); |
| 201 | 211 | const stages = openPullStages(ctx.app.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number))); |
| 202 | 212 | const facts = (r: NextRow): StateFacts => ({ stage: stages.get(r.issue.number) ?? "issue", waits: r.waits, held: r.state === "held", onIt: r.onIt !== null }); |
| 203 | 213 | // One numbered order, folded: the first TOP rows open, what comes after, then Later. |
| 204 | const first = rows.slice(0, TOP); | |
| 205 | const after = rows.slice(TOP); | |
| 206 | const section = (title: string, list: readonly NextRow[], start: number, open: boolean, id?: string): Html => | |
| 207 | html`<details class="mt"${open ? raw(" open") : null}><summary><h2>${title} · ${String(list.length)}</h2></summary> | |
| 208 | <ul class="list setting-list"${id === undefined ? null : html` id="${id}"`}${id !== undefined && costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}> | |
| 209 | ${list.flatMap((_, i) => { | |
| 210 | const at = start + i; | |
| 211 | const mark = at === cut.fits.length && cut.unsure === null && id !== undefined && i > 0 ? [cutMark(cut, false)] : []; | |
| 212 | return [...mark, line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner)]; | |
| 213 | })} | |
| 214 | // A filter hides rows but keeps each one's place in the whole ranking. | |
| 215 | const ats = rows.map((_, at) => at).filter((at) => shows(rows[at]!)); | |
| 216 | const cutAt = cut.fits.length; | |
| 217 | const section = (title: string, list: readonly number[], open: boolean, id?: string): Html => { | |
| 218 | const mark = markBefore(list, cutAt); | |
| 219 | return html`<details class="mt"${open ? raw(" open") : null}><summary><h2>${title} · ${String(list.length)}</h2></summary> | |
| 220 | <ul class="list setting-list"${id === undefined ? null : html` id="${id}"`}${id !== undefined && !isFiltered && costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}> | |
| 221 | ${list.flatMap((at, i) => [ | |
| 222 | ...(i === mark && cut.unsure === null && id !== undefined ? [cutMark(cut, false)] : []), | |
| 223 | line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner), | |
| 224 | ])} | |
| 214 | 225 | </ul></details>`; |
| 226 | }; | |
| 227 | const first = isFiltered ? ats : ats.filter((at) => at < TOP); | |
| 228 | const rest = isFiltered ? [] : ats.filter((at) => at >= TOP); | |
| 229 | const parked = next.notNow.map((_, at) => at).filter((at) => shows(next.notNow[at]!)); | |
| 230 | const nothingWords = filter.badPillar | |
| 231 | ? "That is not a pillar: a pillar is P and digits, or Later." | |
| 232 | : filter.pillar !== null && filter.pillar !== "Later" && !pillars!.pillars.some((p) => p.short === filter.pillar) | |
| 233 | ? `This repository has no pillar ${filter.pillar}.` | |
| 234 | : "Nothing here matches."; | |
| 235 | const bar = html`<form method="get" action="${base}/next" class="form-actions" role="search"> | |
| 236 | <input class="input" name="pillar" type="text" maxlength="8" value="${filter.pillar ?? ""}" placeholder="Pillar: P7 or Later" aria-label="Pillar" autocomplete="off" /> | |
| 237 | <input class="input" name="q" type="search" maxlength="80" value="${filter.q}" placeholder="A word in the title" aria-label="Filter by word" autocomplete="off" /> | |
| 238 | ${btn("Filter", { type: "submit", size: "sm" })} | |
| 239 | ${isFiltered ? html`<a href="${base}/next">Clear</a>` : null} | |
| 240 | </form>`; | |
| 215 | 241 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 216 | 242 | <div data-next> |
| 217 | 243 | ${pageHead("Next up", { sub: "The crew builds strictly from the top." })} |
| 218 | 244 | <p class="error" id="next-flash" role="alert" hidden></p> |
| 219 | 245 | ${rows.length === 0 |
| 220 | 246 | ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>` |
| 221 | : html`${section("Up next", first, 0, true, "next-list")}${after.length > 0 ? section("After", after, TOP, false) : null}`} | |
| 247 | : html`${bar}${isFiltered ? html`<p class="muted" id="next-count">${countWords(ats.length, rows.length)} shown${ats.length + parked.length === 0 ? html`. ${nothingWords}` : null}. Numbers and the cut line are the whole list's; the week's capacity bar is left out while filtered.</p>` : null} | |
| 248 | ${first.length > 0 ? section(isFiltered ? "Matching" : "Up next", first, true, "next-list") : null}${rest.length > 0 ? section("After", rest, false) : null}`} | |
| 222 | 249 | <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer |
| 223 | 250 | ? " · Drag a row, or press the key on its button; j and k move between rows." |
| 224 | 251 | : null}</p> |
| 225 | ${next.notNow.length > 0 | |
| 226 | ? html`<details class="mt"><summary><h2>Later · ${String(next.notNowTotal)}</h2></summary><ul class="list setting-list"> | |
| 227 | ${next.notNow.map((_, at) => line(base, next.notNow, at, writer, back, true))} | |
| 252 | ${parked.length > 0 | |
| 253 | ? html`<details class="mt"><summary><h2>Later · ${String(isFiltered ? parked.length : next.notNowTotal)}</h2></summary><ul class="list setting-list"> | |
| 254 | ${parked.map((at) => line(base, next.notNow, at, writer, back, true))} | |
| 228 | 255 | </ul></details>` |
| 229 | 256 | : null} |
| 230 | 257 | ${next.unlisted > 0 |
src/web/pages/next_filter.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * The /next filter (#11867): `?pillar=P7|Later` and `?q=word`, both read at | |
| 3 | * the boundary into plain data. A filter only hides rows; ranks, the cut line | |
| 4 | * and every move stay those of the whole ranking. | |
| 5 | */ | |
| 6 | export interface NextFilter { | |
| 7 | /** `P7` or `Later`, as asked; null when none was asked. */ | |
| 8 | readonly pillar: string | null; | |
| 9 | /** What was typed, trimmed and capped; matching ignores its case, the input shows it as typed. */ | |
| 10 | readonly q: string; | |
| 11 | /** A pillar value that is not `P` and digits or `Later`: it matches nothing. */ | |
| 12 | readonly badPillar: boolean; | |
| 13 | } | |
| 14 | ||
| 15 | export const MAX_Q = 80; | |
| 16 | const PILLAR_SHAPE = /^(P[0-9]{1,6}|Later)$/; | |
| 17 | ||
| 18 | export function readFilter(params: URLSearchParams): NextFilter { | |
| 19 | const asked = (params.get("pillar") ?? "").trim(); | |
| 20 | const q = (params.get("q") ?? "").trim().slice(0, MAX_Q); | |
| 21 | if (asked === "") return { pillar: null, q, badPillar: false }; | |
| 22 | if (!PILLAR_SHAPE.test(asked)) return { pillar: null, q, badPillar: true }; | |
| 23 | return { pillar: asked, q, badPillar: false }; | |
| 24 | } | |
| 25 | ||
| 26 | export const filtered = (f: NextFilter): boolean => f.pillar !== null || f.q !== "" || f.badPillar; | |
| 27 | ||
| 28 | /** | |
| 29 | * Whether a row with this title, served by pillar name `served` (`P7 Trust`, `Later`, or none), | |
| 30 | * shows. | |
| 31 | */ | |
| 32 | export function matches(f: NextFilter, title: string, served: string | undefined): boolean { | |
| 33 | if (f.badPillar) return false; | |
| 34 | if (f.pillar !== null && (served === undefined || served.split(" ")[0] !== f.pillar)) return false; | |
| 35 | return f.q === "" || title.toLowerCase().includes(f.q.toLowerCase()); | |
| 36 | } | |
| 37 | ||
| 38 | /** The path back to this page with the filter, for a move form to return to. */ | |
| 39 | export function filterPath(base: string, f: NextFilter, rawPillar: string): string { | |
| 40 | const parts: string[] = []; | |
| 41 | if (f.pillar !== null || f.badPillar) parts.push(`pillar=${encodeURIComponent(rawPillar.trim().slice(0, 40))}`); | |
| 42 | if (f.q !== "") parts.push(`q=${encodeURIComponent(f.q)}`); | |
| 43 | return parts.length === 0 ? base : `${base}?${parts.join("&")}`; | |
| 44 | } | |
| 45 | ||
| 46 | /** The page's own wording for how many rows show. */ | |
| 47 | export function countWords(shown: number, total: number): string { | |
| 48 | return `${shown} of ${total}`; | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Where the cut line falls among the shown rows (their places in the whole | |
| 53 | * ranking): before the first one at or past `cutAt`, as an index, or -1 when | |
| 54 | * every shown row is on one side of it. A filter never moves the line, it only | |
| 55 | * hides it when no shown row is under it. | |
| 56 | */ | |
| 57 | export function markBefore(shown: readonly number[], cutAt: number): number { | |
| 58 | const i = shown.findIndex((at) => at >= cutAt); | |
| 59 | return i > 0 ? i : -1; | |
| 60 | } |
test/next-filter.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** /next narrows to one pillar or a word, keeping the owner's order and real numbers (#11867). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { rankIssue } from "../src/actions/next.ts"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { createSession } from "../src/core/sessions.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { filterPath, markBefore, matches, readFilter } from "../src/web/pages/next_filter.ts"; | |
| 14 | ||
| 15 | let dir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let cookie: string; | |
| 19 | const nums: Record<string, number> = {}; | |
| 20 | let alice: Parameters<typeof rankIssue>[1]; | |
| 21 | let repo: Parameters<typeof rankIssue>[2]; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dir = await mkdtemp(join(tmpdir(), "forge-next-filter-")); | |
| 25 | app = createApp({ dataDir: dir, port: 0 }); | |
| 26 | servers = await serve(app); | |
| 27 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 28 | if (!made.ok) throw new Error(made.error); | |
| 29 | alice = made.value; | |
| 30 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 31 | const r = await createRepo(app, alice, { name: "filt", visibility: "public" }); | |
| 32 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 33 | repo = r.value; | |
| 34 | const open = (title: string): number => { | |
| 35 | const o = openIssue(app, alice, repo, { title }); | |
| 36 | if (!o.ok) throw new Error(String(o.error)); | |
| 37 | nums[title] = o.value.number; | |
| 38 | return o.value.number; | |
| 39 | }; | |
| 40 | const dep = (a: number, b: number) => | |
| 41 | app.db.run("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES ((SELECT id FROM issue WHERE repo_id=?1 AND number=?2),(SELECT id FROM issue WHERE repo_id=?1 AND number=?3))", [repo.id, a, b]); | |
| 42 | const u7 = open("P7 Speed: be quick"); | |
| 43 | const u1 = open("P1 Trust: be safe"); | |
| 44 | const titles = ["alpha cache", "beta cache", "gamma safe", "delta cache", "epsilon"]; | |
| 45 | const made5 = titles.map(open); | |
| 46 | dep(u7, made5[0]!); | |
| 47 | dep(u7, made5[1]!); | |
| 48 | dep(u1, made5[2]!); | |
| 49 | dep(u7, made5[3]!); | |
| 50 | dep(u1, made5[4]!); | |
| 51 | }); | |
| 52 | ||
| 53 | afterAll(async () => { | |
| 54 | await servers.stop(); | |
| 55 | app.close(); | |
| 56 | await rm(dir, { recursive: true, force: true }); | |
| 57 | }); | |
| 58 | ||
| 59 | const get = async (qs: string) => { | |
| 60 | const res = await fetch(`${servers.url}/alice/filt/next${qs}`, { headers: { cookie } }); | |
| 61 | return { status: res.status, text: await res.text() }; | |
| 62 | }; | |
| 63 | const numbersOf = (page: string) => [...page.matchAll(/<li[^>]*data-number="(\d+)"/g)].map((m) => Number(m[1])); | |
| 64 | ||
| 65 | test("a word keeps the owner's order and the real numbers, and counts in words", async () => { | |
| 66 | const all = numbersOf((await get("")).text); | |
| 67 | const page = (await get("?q=CACHE")).text; | |
| 68 | const shown = numbersOf(page); | |
| 69 | const want = [nums["alpha cache"]!, nums["beta cache"]!, nums["delta cache"]!]; | |
| 70 | expect(shown).toEqual(all.filter((n) => want.includes(n))); | |
| 71 | expect(shown.length).toBe(3); | |
| 72 | expect(page).toContain(`3 of ${all.length} shown`); | |
| 73 | // What was typed is kept for the input; only matching ignores case. | |
| 74 | expect(page).toContain('value="CACHE"'); | |
| 75 | expect(page).toContain("capacity bar is left out while filtered"); | |
| 76 | // Rank numbers are the full ranking's, not 1, 2, 3. | |
| 77 | for (const n of shown) expect(page).toContain(`>${all.indexOf(n) + 1}<`); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a pillar shows only what serves it", async () => { | |
| 81 | const page = (await get("?pillar=P1")).text; | |
| 82 | expect(numbersOf(page).sort()).toEqual([nums["gamma safe"]!, nums["epsilon"]!].sort()); | |
| 83 | expect(page).toContain("2 of"); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a move under a filter carries the filter back and moves in the full ranking", async () => { | |
| 87 | const page = (await get("?q=cache&pillar=P7")).text; | |
| 88 | const forms = [...page.matchAll(/name="back" value="([^"]*)"/g)].map((m) => m[1]); | |
| 89 | expect(forms.length).toBeGreaterThan(0); | |
| 90 | for (const f of forms) expect(f).toBe("/alice/filt/next?pillar=P7&q=cache"); | |
| 91 | const before = numbersOf((await get("")).text); | |
| 92 | const target = nums["delta cache"]!; | |
| 93 | const res = await fetch(`${servers.url}/alice/filt/next`, { | |
| 94 | method: "POST", | |
| 95 | redirect: "manual", | |
| 96 | headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 97 | body: new URLSearchParams({ number: String(target), to: "top", back: "/alice/filt/next?pillar=P7&q=cache" }), | |
| 98 | }); | |
| 99 | expect(res.status).toBe(303); | |
| 100 | expect(res.headers.get("location")).toContain("/alice/filt/next?pillar=P7&q=cache"); | |
| 101 | const after = numbersOf((await get("")).text); | |
| 102 | expect(after[0]).toBe(target); | |
| 103 | expect(after.length).toBe(before.length); | |
| 104 | }); | |
| 105 | ||
| 106 | test("bad input is bounded and escaped; an unknown pillar is an empty list, not an error", async () => { | |
| 107 | const unknown = await get("?pillar=P99"); | |
| 108 | expect(unknown.status).toBe(200); | |
| 109 | expect(numbersOf(unknown.text)).toEqual([]); | |
| 110 | expect(unknown.text).toContain("no pillar P99"); | |
| 111 | const bad = await get("?pillar=%3Cscript%3E"); | |
| 112 | expect(bad.status).toBe(200); | |
| 113 | expect(bad.text).not.toContain("<script>alert"); | |
| 114 | expect(numbersOf(bad.text)).toEqual([]); | |
| 115 | const xss = await get(`?q=${encodeURIComponent('"><img src=x onerror=alert(1)>')}`); | |
| 116 | expect(xss.status).toBe(200); | |
| 117 | expect(xss.text).not.toContain("<img src=x"); | |
| 118 | expect(readFilter(new URLSearchParams({ q: "x".repeat(200) })).q.length).toBe(80); | |
| 119 | expect(readFilter(new URLSearchParams({ q: "CaChe" })).q).toBe("CaChe"); | |
| 120 | expect(matches(readFilter(new URLSearchParams({ q: "CaChe" })), "a cache", undefined)).toBe(true); | |
| 121 | expect(matches(readFilter(new URLSearchParams("pillar=Later")), "t", "P1 Trust")).toBe(false); | |
| 122 | expect(filterPath("/b", readFilter(new URLSearchParams("")), "")).toBe("/b"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the cut line stays at the whole ranking's place: before the first shown row past it, never at an edge", () => { | |
| 126 | expect(markBefore([0, 3, 7, 9], 5)).toBe(2); | |
| 127 | expect(markBefore([6, 7], 5)).toBe(-1); // every shown row is under it: no line to draw above them | |
| 128 | expect(markBefore([0, 1], 5)).toBe(-1); // every shown row fits: no line below them | |
| 129 | expect(markBefore([], 5)).toBe(-1); | |
| 130 | }); |
test/next-up.test.ts +1 −1
| @@ -368,7 +368,7 @@ describe("Next up page shape (#9764)", () => { | ||
| 368 | 368 | for (const label of ["Top", "Up", "Down", "Not now"]) { |
| 369 | 369 | expect(body).toMatch(new RegExp(`<form method="post" action="[^"]*/next"[^>]*>(?:(?!</form>).)*${label} <kbd>`)); |
| 370 | 370 | } |
| 371 | expect(body).not.toMatch(/<form(?![^>]*method="post")[^>]*action="[^"]*\/next"/); | |
| 371 | expect(body).not.toMatch(/<form(?![^>]*method="(?:post|get)")[^>]*action="[^"]*\/next"/); // the filter is the one GET form | |
| 372 | 372 | |
| 373 | 373 | // Not now (?all=1) is rows under its own heading too, and Top is the one move it offers. |
| 374 | 374 | const gate = open("Later gate: not now"); |
test/tui-next-up.test.ts +25
| @@ -190,4 +190,29 @@ describe("next up in the terminal", () => { | ||
| 190 | 190 | expect(h.text()).not.toContain("Secret work"); |
| 191 | 191 | h.stop(); |
| 192 | 192 | }); |
| 193 | ||
| 194 | test("/ narrows to a word, ranks stay the whole list's, a move still moves in the whole order, and an empty filter clears it (#11867)", async () => { | |
| 195 | const h = boot(alice); | |
| 196 | await h.toNext("alice/plan"); | |
| 197 | const before = order(); | |
| 198 | expect(h.text()).toContain("Work a"); | |
| 199 | await h.press("/"); | |
| 200 | for (const ch of "WORK B") await h.press(ch); | |
| 201 | await h.press("enter"); | |
| 202 | const text = h.text(); | |
| 203 | expect(text).toMatch(/1 of \d+ shown/); | |
| 204 | expect(text).toContain("Work b"); | |
| 205 | expect(text).not.toContain("Work a"); | |
| 206 | expect(text).toContain(`${before.indexOf(n.b!) + 1}. #` + n.b); // its place in the whole ranking | |
| 207 | // Top moves the row under the cursor in the whole order, not in the shown one. | |
| 208 | await h.press("t"); | |
| 209 | expect(order()[0]).toBe(n.b!); | |
| 210 | expect(order()).toHaveLength(before.length); | |
| 211 | // Clear: empty submit. | |
| 212 | await h.press("/"); | |
| 213 | for (let i = 0; i < 6; i++) await h.press("backspace"); | |
| 214 | await h.press("enter"); | |
| 215 | expect(h.text()).toContain("Work a"); | |
| 216 | h.stop(); | |
| 217 | }); | |
| 193 | 218 | }); |
new above
offline
q is lowercased on read, and the page echoes it back into the input as lowercase. Keep the typed text for display; lowercase in matches().