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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11819-explorer-filters-live-in-the-url-a-filte → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/11819-explorer-filters-live-in-the-url-a-filte was merged into main as 10fcfcd.

Review

Sign in to review this change.
3 files changed +43 −8
src/web/studio/explore.ts +12 −3
@@ -146,6 +146,15 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html {
146146 });
147147 }
148148
149 /** The active filters as chips, each a link to the same view without it. */
outdated

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 above removable, and give chipRow its own one-line comment.

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``;

aria-label on a plain div is ignored by most screen readers. Add role="group". Principle 8: the chip text is "kind:app ×", which reads as syntax; the title carries the plain words but the visible label should too.

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
149158 /** What the filter's terms are, for a link that takes one away: the owner too, which `terms` leaves to the line. */
150159 function removable(query: ExploreQuery): { term: string; without: ExploreQuery }[] {
151160 const out = terms(query).filter((t) => !t.term.startsWith("sort:"));
@@ -232,6 +241,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
232241 />
233242 ${btn("Go", { size: "sm" })}
234243 </form>
244 ${chipRow(query)}
235245 ${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``}
236246 ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}${
237247 suggestion === undefined
@@ -280,8 +290,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
280290 ? html``
281291 : html`<div class="explore-more">${btn("More results", { href: `${here}${here.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(more)}`, variant: "outline", extra: "explore-more-link" })}</div>`}</div>`;
282292 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.
285294 notePhase(ctx.request, "render", performance.now() - rendering);
286 return response;
295 return drawn;
287296 }
test/explore-parity.test.ts +2 −2
@@ -192,7 +192,7 @@ describe("one filter, three surfaces", () => {
192192 }
193193 });
194194
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 () => {
196196 const q = encodeURIComponent("sort:bogus");
197197 const api = await fetch(`${servers.url}/api/v1/explore?q=${q}`);
198198 expect(api.status).toBe(400);
@@ -203,7 +203,7 @@ describe("one filter, three surfaces", () => {
203203 expect(tool.ok).toBe(false);
204204 expect(tool.text).toContain("use score, forks, ships, likes, use, fresh, name");
205205 const page = await fetch(`${servers.url}/explore?q=${q}`);
206 expect(page.status).toBe(400);
206 expect(page.status).toBe(200);
207207 expect(await page.text()).toContain("use score, forks, ships, likes, use, fresh, name");
208208 for (const bad of ["kind:x", "updated:2y", "archived:maybe"]) {
209209 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
7474 expect(extra.status).toBe(200);
7575 expect(listed(extra.html)).toEqual(listed(plain.html));
7676 const bad = await page("?sort=bogus");
77 expect(bad.status).toBe(400);
77 expect(bad.status).toBe(200);
7878 expect(listed(bad.html)).toEqual(listed(plain.html));
7979 });
8080
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
8193 test("a value is one word and is escaped", async () => {
8294 expect(exploreLineFrom((n) => (n === "kind" ? "app garden" : undefined))).toBe("kind:appgarden");
8395 expect(readExploreLine(exploreLineFrom((n) => (n === "owner" ? "<script>" : undefined))).unknown).toEqual(["owner:<script>"]);
@@ -92,13 +104,13 @@ test("the chips link with separate parameters", async () => {
92104 expect(exploreParams(readExploreLine("kind:app sort:forks owner:olive garden").query)).toBe("kind=app&sort=forks&owner=olive&q=garden");
93105 });
94106
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 () => {
96108 expect(exploreLineFrom((n) => (n === "since" ? "7d" : null))).toBe("updated:7d");
97109 expect(exploreLineFrom((n) => (n === "since" ? "9y" : null))).toBe("updated:9y");
98110 const ok = await fetch(`${servers.url}/explore?kind=app&since=7d`);
99111 expect(ok.status).toBe(200);
100112 const bad = await fetch(`${servers.url}/explore?since=9y`);
101 expect(bad.status).toBe(400);
113 expect(bad.status).toBe(200);
102114 expect(await bad.text()).toContain("1d, 7d, 30d");
103115 });
104116
@@ -133,3 +145,17 @@ test("the chips link with since=, and updated wins when both are given (#11232)"
133145 expect(res.html).not.toContain("updated=");
134146 expect(exploreLineFrom((n) => (n === "updated" ? "7d" : n === "since" ? "30d" : null))).toBe("updated:7d");
135147 });
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&amp;owner=olive&amp;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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs