Public: anyone can read this; the crew can change it.
Explorer filters live in the URL: a filtered view is a link, opens server-rendered, and survives a reload #11853
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11819-explorer-filters-live-in-the-url-a-filte → main 3 commits fork 2 threads · 2 unresolved
3 files
changed +43
−8
src/web/studio/explore.ts +12 −3
| @@ -146,6 +146,15 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html { | ||
| 146 | 146 | }); |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | /** The active filters as chips, each a link to the same view without it. */ | |
| 150 | function chipRow(query: ExploreQuery): Html { | |
| 151 | const chips = [...removable(query), ...terms(query).filter((t) => t.term.startsWith("sort:"))]; | |
| 152 | if (chips.length === 0) return html``; | |
|
||
| 153 | return html`<div class="bar mb small" data-filter-chips role="group" aria-label="Active filters"> | |
| 154 | ${chips.map((t) => btn(html`${shorten(t.term)} <span aria-hidden="true">×</span>`, { href: href(t.without), size: "sm", title: `Remove ${t.term}`, label: `Remove filter ${t.term}` }))} | |
| 155 | </div>`; | |
| 156 | } | |
| 157 | ||
| 149 | 158 | /** What the filter's terms are, for a link that takes one away: the owner too, which `terms` leaves to the line. */ |
| 150 | 159 | function removable(query: ExploreQuery): { term: string; without: ExploreQuery }[] { |
| 151 | 160 | const out = terms(query).filter((t) => !t.term.startsWith("sort:")); |
| @@ -232,6 +241,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 232 | 241 | /> |
| 233 | 242 | ${btn("Go", { size: "sm" })} |
| 234 | 243 | </form> |
| 244 | ${chipRow(query)} | |
| 235 | 245 | ${returned ? html`<p class="muted mb" role="status">That page link is not one for this list, so this is the first page.</p>` : html``} |
| 236 | 246 | ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}${ |
| 237 | 247 | suggestion === undefined |
| @@ -280,8 +290,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 280 | 290 | ? html`` |
| 281 | 291 | : html`<div class="explore-more">${btn("More results", { href: `${here}${here.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(more)}`, variant: "outline", extra: "explore-more-link" })}</div>`}</div>`; |
| 282 | 292 | const drawn = page({ ctx, title: "Explore", body, feed: { href: "/explore.atom", title: "Explore" } }); |
| 283 | // An unknown filter is a 400 here as on the API and the MCP; the page still draws the note and the results. | |
| 284 | const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 400, headers: drawn.headers }); | |
| 293 | // An unknown filter is ignored with a plain note on the page (200); the API and the MCP stay strict. | |
| 285 | 294 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 286 | return response; | |
| 295 | return drawn; | |
| 287 | 296 | } |
test/explore-parity.test.ts +2 −2
| @@ -192,7 +192,7 @@ describe("one filter, three surfaces", () => { | ||
| 192 | 192 | } |
| 193 | 193 | }); |
| 194 | 194 | |
| 195 | test("an unknown filter is a 400 naming the allowed words, on all three", async () => { | |
| 195 | test("an unknown filter is a 400 naming the allowed words on the API and the MCP; the page answers 200 with the note", async () => { | |
| 196 | 196 | const q = encodeURIComponent("sort:bogus"); |
| 197 | 197 | const api = await fetch(`${servers.url}/api/v1/explore?q=${q}`); |
| 198 | 198 | expect(api.status).toBe(400); |
| @@ -203,7 +203,7 @@ describe("one filter, three surfaces", () => { | ||
| 203 | 203 | expect(tool.ok).toBe(false); |
| 204 | 204 | expect(tool.text).toContain("use score, forks, ships, likes, use, fresh, name"); |
| 205 | 205 | const page = await fetch(`${servers.url}/explore?q=${q}`); |
| 206 | expect(page.status).toBe(400); | |
| 206 | expect(page.status).toBe(200); | |
| 207 | 207 | expect(await page.text()).toContain("use score, forks, ships, likes, use, fresh, name"); |
| 208 | 208 | for (const bad of ["kind:x", "updated:2y", "archived:maybe"]) { |
| 209 | 209 | expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(bad)}`)).status).toBe(400); |
test/explore-url.test.ts +29 −3
| @@ -74,10 +74,22 @@ test("an unknown parameter is ignored; an invalid value is named and the default | ||
| 74 | 74 | expect(extra.status).toBe(200); |
| 75 | 75 | expect(listed(extra.html)).toEqual(listed(plain.html)); |
| 76 | 76 | const bad = await page("?sort=bogus"); |
| 77 | expect(bad.status).toBe(400); | |
| 77 | expect(bad.status).toBe(200); | |
| 78 | 78 | expect(listed(bad.html)).toEqual(listed(plain.html)); |
| 79 | 79 | }); |
| 80 | 80 | |
| 81 | test("an unknown kind, sort or updated is a 200 with a plain note and the unfiltered list, escaped (#11819)", async () => { | |
| 82 | const plain = listed((await page("")).html); | |
| 83 | for (const qs of ["?kind=bogus", "?sort=bogus", "?updated=9y", "?since=9y", `?kind=${encodeURIComponent("<script>x</script>")}`]) { | |
| 84 | const res = await page(qs); | |
| 85 | expect(res.status).toBe(200); | |
| 86 | expect(res.html).toContain('role="status"'); | |
| 87 | expect(res.html).toMatch(/is not a (kind|sort|window)/); | |
| 88 | expect(res.html).not.toContain("<script>x</script>"); | |
| 89 | expect(listed(res.html)).toEqual(plain); | |
| 90 | } | |
| 91 | }); | |
| 92 | ||
| 81 | 93 | test("a value is one word and is escaped", async () => { |
| 82 | 94 | expect(exploreLineFrom((n) => (n === "kind" ? "app garden" : undefined))).toBe("kind:appgarden"); |
| 83 | 95 | expect(readExploreLine(exploreLineFrom((n) => (n === "owner" ? "<script>" : undefined))).unknown).toEqual(["owner:<script>"]); |
| @@ -92,13 +104,13 @@ test("the chips link with separate parameters", async () => { | ||
| 92 | 104 | expect(exploreParams(readExploreLine("kind:app sort:forks owner:olive garden").query)).toBe("kind=app&sort=forks&owner=olive&q=garden"); |
| 93 | 105 | }); |
| 94 | 106 | |
| 95 | test("`since` is the `updated` window: same line, bad value is a 400 (#11232)", async () => { | |
| 107 | test("`since` is the `updated` window: same line, bad value is a noted 200 (#11232)", async () => { | |
| 96 | 108 | expect(exploreLineFrom((n) => (n === "since" ? "7d" : null))).toBe("updated:7d"); |
| 97 | 109 | expect(exploreLineFrom((n) => (n === "since" ? "9y" : null))).toBe("updated:9y"); |
| 98 | 110 | const ok = await fetch(`${servers.url}/explore?kind=app&since=7d`); |
| 99 | 111 | expect(ok.status).toBe(200); |
| 100 | 112 | const bad = await fetch(`${servers.url}/explore?since=9y`); |
| 101 | expect(bad.status).toBe(400); | |
| 113 | expect(bad.status).toBe(200); | |
| 102 | 114 | expect(await bad.text()).toContain("1d, 7d, 30d"); |
| 103 | 115 | }); |
| 104 | 116 | |
| @@ -133,3 +145,17 @@ test("the chips link with since=, and updated wins when both are given (#11232)" | ||
| 133 | 145 | expect(res.html).not.toContain("updated="); |
| 134 | 146 | expect(exploreLineFrom((n) => (n === "updated" ? "7d" : n === "since" ? "30d" : null))).toBe("updated:7d"); |
| 135 | 147 | }); |
| 148 | ||
| 149 | test("active filters show as a chip row whose removes are links, escaped (#11819)", async () => { | |
| 150 | expect((await page("")).html).not.toContain("data-filter-chips"); | |
| 151 | const { html } = await page("?kind=app&sort=forks&owner=olive&q=garden"); | |
| 152 | const row = /data-filter-chips[\s\S]*?<\/div>/.exec(html)![0]; | |
| 153 | expect(row).toContain('href="/explore?sort=forks&owner=olive&q=garden"'); | |
| 154 | expect(row).toContain("Remove kind:app"); | |
| 155 | expect(row).toContain('role="group"'); | |
| 156 | expect(row).toContain("Remove sort:forks"); | |
| 157 | expect(row).toContain("Remove owner:olive"); | |
| 158 | expect(row).toContain("Remove garden"); | |
| 159 | const hostile = await page(`?q=${encodeURIComponent('"><img src=x onerror=1>')}`); | |
| 160 | expect(hostile.html).not.toContain("<img src=x"); | |
| 161 | }); |
new above
offline
The doc comment about
removable("What the filter's terms are, for a link that takes one away") now sits on chipRow. Move it back aboveremovable, and give chipRow its own one-line comment.