Public: anyone can read this; the crew can change it.
When /explore finds nothing, it names what was searched and offers each term as a link that drops it #5972
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5959-when-explore-finds-nothing-it-names-what → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +61
−4
src/web/studio/explore.ts +33 −3
| @@ -56,13 +56,43 @@ function rowHtml(row: ExploreRow): Html { | ||
| 56 | 56 | }); |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | /** A button label that cannot push the page wider; the whole term is its title. */ | |
| 60 | const shorten = (term: string): string => | |
| 61 | term.length > 24 ? `${term.slice(0, 23)}…` : term; | |
| 62 | ||
| 63 | /** Each term of a missed line, with the query that is the line without it. */ | |
| 64 | function terms(query: ExploreQuery): { term: string; without: ExploreQuery }[] { | |
| 65 | const out: { term: string; without: ExploreQuery }[] = []; | |
| 66 | if (query.kind !== "all") | |
| 67 | out.push({ term: `kind:${query.kind}`, without: { ...query, kind: "all" } }); | |
| 68 | if (query.sort !== "score") | |
| 69 | out.push({ term: `sort:${query.sort}`, without: { ...query, sort: "score" } }); | |
| 70 | if (query.archived) | |
| 71 | out.push({ term: "archived:yes", without: { ...query, archived: false } }); | |
| 72 | const words = query.text.split(/\s+/).filter((w) => w !== ""); | |
| 73 | words.forEach((word, i) => | |
| 74 | out.push({ | |
| 75 | term: word, | |
| 76 | without: { ...query, text: words.filter((_, j) => j !== i).join(" ") }, | |
| 77 | }), | |
| 78 | ); | |
| 79 | return out; | |
| 80 | } | |
| 81 | ||
| 59 | 82 | /** Nothing to list: a bare line means nothing is public yet, not a miss. */ |
| 60 | 83 | function emptyState(signedIn: boolean, query: ExploreQuery): Html { |
| 61 | if (exploreLine(query) !== "") | |
| 84 | const line = exploreLine(query); | |
| 85 | if (line !== "") | |
| 62 | 86 | return empty({ |
| 63 | 87 | icon: "search", |
| 64 | title: "Nothing matches", | |
| 65 | note: "Clear the line to see everything you can.", | |
| 88 | title: `Nothing matches ${line}`, | |
| 89 | note: "Drop a term to step back:", | |
| 90 | action: html`<div class="form-actions"> | |
| 91 | ${terms(query).map( | |
| 92 | (t) => btn(shorten(t.term), { href: href(t.without), size: "sm", title: `Drop ${t.term}` }), | |
| 93 | )} | |
| 94 | ${btn("Clear all", { href: "/explore", size: "sm" })} | |
| 95 | </div>`, | |
| 66 | 96 | }); |
| 67 | 97 | return empty({ |
| 68 | 98 | icon: "search", |
src/web/style/components.ts +1
| @@ -840,6 +840,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 840 | 840 | .empty .ico { color: var(--faint); width: 20px; height: 20px; } |
| 841 | 841 | .empty h2, .empty .empty-title { |
| 842 | 842 | font-size: 15px; font-weight: 600; color: var(--fg); text-wrap: balance; margin: 0; |
| 843 | overflow-wrap: anywhere; | |
| 843 | 844 | } |
| 844 | 845 | .empty p { font-size: 13px; max-width: 320px; text-wrap: pretty; margin: 0; } |
| 845 | 846 | .empty .btn { margin-top: 8px; } |
test/explore.test.ts +27 −1
| @@ -326,8 +326,34 @@ describe("explore on a forge with nothing public", () => { | ||
| 326 | 326 | for (const signedIn of [false, true]) { |
| 327 | 327 | const out = await get(`/explore?q=${encodeURIComponent("zzz-no-such")}`, signedIn); |
| 328 | 328 | expect(out).toContain("Nothing matches"); |
| 329 | expect(out).toContain("Clear the line"); | |
| 329 | expect(out).toContain("Nothing matches zzz-no-such"); | |
| 330 | expect(out).toContain("Clear all"); | |
| 330 | 331 | expect(out).not.toContain("Nothing public yet"); |
| 331 | 332 | } |
| 332 | 333 | }); |
| 334 | ||
| 335 | test("a miss names the line and offers each term as a link that drops it", async () => { | |
| 336 | const out = await get(`/explore?q=${encodeURIComponent("kind:app zzz-no-such")}`, false); | |
| 337 | expect(out).toContain("Nothing matches kind:app zzz-no-such"); | |
| 338 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("zzz-no-such")}"`); | |
| 339 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("kind:app")}"`); | |
| 340 | expect(out).toContain('href="/explore"'); | |
| 341 | }); | |
| 342 | ||
| 343 | test("many words and a long one sit in a wrapping group, not a list row", async () => { | |
| 344 | const words = Array.from({ length: 10 }, (_, i) => `w${i}`).join(" "); | |
| 345 | const long = "x".repeat(60); | |
| 346 | const out = await get(`/explore?q=${encodeURIComponent(`${words} ${long}`)}`, false); | |
| 347 | const group = out.slice(out.indexOf("Drop a term")); | |
| 348 | expect(group).toContain('class="form-actions"'); | |
| 349 | expect(group.slice(0, group.indexOf("Clear all"))).not.toContain('class="row"'); | |
| 350 | expect(group).toContain(`>${"x".repeat(23)}…</a`); | |
| 351 | expect(group).toContain(`title="Drop ${long}"`); | |
| 352 | }); | |
| 353 | ||
| 354 | test("a hostile word is escaped in the title and the links", async () => { | |
| 355 | const out = await get(`/explore?q=${encodeURIComponent("<script>x</script>")}`, false); | |
| 356 | expect(out).not.toContain("<script>x"); | |
| 357 | expect(out).toContain("Nothing matches <script>x</script>"); | |
| 358 | }); | |
| 333 | 359 | }); |
new above
offline
class="row" is the list-row component, not a button group: it is flex without wrap, so many terms overflow at 360px. Use a wrapping group from the design system instead.