Let the search reach past the twentieth of a kind, and unbreak two specs
The global search capped each category before cmdk had matched anything, so nothing past the twentieth node or widget could be found at all — this instance has 28 nodes and 97 widgets. The cap now trims the candidates the query could reach rather than the raw index. The admin teardown asked /users/ for limit=1000, which the route stopped accepting when its bounds went in; a 422 body has no `data` to iterate, so the hook threw and took the tests it was attributed to with it. It asks for the 500 the route allows. And `submit` folds every declared initial into a run's params, so an input the run never passed can no longer read as the flow's own. That assertion is gone; what the panel does show — the value the run actually started from, passed or not — is what the test checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
This commit is contained in:
@@ -31,9 +31,11 @@ export const searchQueryOptions = () => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** The categories, in the order they are offered, with what to draw each as. */
|
/** The categories, in the order they are offered, with what to draw each as. */
|
||||||
//: The most entries one group shows. cmdk scores what is mounted, so this is
|
//: The most entries one group shows. cmdk scores what is mounted, so the cap
|
||||||
//: a cap on what is offered rather than on what is searched — and twenty of a
|
//: has to come after the query has had its say, never before: capping the raw
|
||||||
//: kind is already more than anybody reads before typing another letter.
|
//: index instead put everything past the twentieth node or widget beyond reach
|
||||||
|
//: of the search altogether. Twenty of what matches is already more than
|
||||||
|
//: anybody reads before typing another letter.
|
||||||
const GROUP_CAP = 20
|
const GROUP_CAP = 20
|
||||||
|
|
||||||
const GROUPS: {
|
const GROUPS: {
|
||||||
@@ -57,6 +59,31 @@ function hint(entry: SearchEntry): string {
|
|||||||
return [entry.parent, entry.kind].filter(Boolean).join(" · ")
|
return [entry.parent, entry.kind].filter(Boolean).join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Everything about an entry that is worth matching against. */
|
||||||
|
function searchValue(entry: SearchEntry): string {
|
||||||
|
return `${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cmdk lowercases and flattens whitespace and hyphens before it scores. */
|
||||||
|
function normalise(text: string): string {
|
||||||
|
return text.toLowerCase().replace(/[\s-]/g, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether cmdk could score this entry at all: it needs the query's characters
|
||||||
|
* in the value, in order. Asking the cheap half of that question here is what
|
||||||
|
* lets GROUP_CAP cap the candidates rather than the index.
|
||||||
|
*/
|
||||||
|
function couldMatch(value: string, query: string): boolean {
|
||||||
|
const text = normalise(value)
|
||||||
|
let from = 0
|
||||||
|
for (const char of normalise(query)) {
|
||||||
|
from = text.indexOf(char, from) + 1
|
||||||
|
if (from === 0) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything in this instance, by name, from anywhere.
|
* Everything in this instance, by name, from anywhere.
|
||||||
*
|
*
|
||||||
@@ -85,12 +112,16 @@ export function GlobalSearch({
|
|||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
const entries = data ?? []
|
const entries = data ?? []
|
||||||
const typing = query.trim().length > 0
|
const typed = query.trim()
|
||||||
|
const typing = typed.length > 0
|
||||||
// One pass over the index rather than one per group — nine passes over
|
// One pass over the index rather than one per group — nine passes over
|
||||||
// every entry on each keystroke. Not memoised: the component returns early
|
// every entry on each keystroke. Not memoised: the component returns early
|
||||||
// while closed, so a hook cannot go here, and one pass is already the win.
|
// while closed, so a hook cannot go here, and one pass is already the win.
|
||||||
|
// The same pass drops what the query cannot reach, so each bucket is already
|
||||||
|
// candidates by the time GROUP_CAP trims it.
|
||||||
const byCategory = new Map<string, SearchEntry[]>()
|
const byCategory = new Map<string, SearchEntry[]>()
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
if (!couldMatch(searchValue(entry), typed)) continue
|
||||||
const bucket = byCategory.get(entry.category)
|
const bucket = byCategory.get(entry.category)
|
||||||
if (bucket) bucket.push(entry)
|
if (bucket) bucket.push(entry)
|
||||||
else byCategory.set(entry.category, [entry])
|
else byCategory.set(entry.category, [entry])
|
||||||
@@ -167,7 +198,7 @@ export function GlobalSearch({
|
|||||||
{found.map((entry) => (
|
{found.map((entry) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={`${category}:${entry.parent ?? ""}:${entry.name}`}
|
key={`${category}:${entry.parent ?? ""}:${entry.name}`}
|
||||||
value={`${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}`}
|
value={searchValue(entry)}
|
||||||
onSelect={() => go(entry)}
|
onSelect={() => go(entry)}
|
||||||
className="min-h-11 md:min-h-8"
|
className="min-h-11 md:min-h-8"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ test.afterAll(async ({ browser }) => {
|
|||||||
// sweep them all up by that shape — including what a run that failed halfway
|
// sweep them all up by that shape — including what a run that failed halfway
|
||||||
// left in the shared development database.
|
// left in the shared development database.
|
||||||
const page = await apiPage(browser)
|
const page = await apiPage(browser)
|
||||||
const { data } = await (await api(page, "/users/?limit=1000")).json()
|
// 500 is the most the route accepts; asking for more is a 422, and a 422 body
|
||||||
|
// has no `data` to iterate.
|
||||||
|
const { data } = await (await api(page, "/users/?limit=500")).json()
|
||||||
for (const user of data as { id: string; email: string }[]) {
|
for (const user of data as { id: string; email: string }[]) {
|
||||||
if (/^test_.*@example\.com$/.test(user.email)) {
|
if (/^test_.*@example\.com$/.test(user.email)) {
|
||||||
await api(page, `/users/${user.id}`, { method: "DELETE" })
|
await api(page, `/users/${user.id}`, { method: "DELETE" })
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
data: {
|
data: {
|
||||||
name: flowName,
|
name: flowName,
|
||||||
title: "Runs under test",
|
title: "Runs under test",
|
||||||
// `rate` is declared and never passed, which is what puts a default in
|
// `rate` is declared and never passed, which is what puts its declared
|
||||||
// the Inputs panel below.
|
// value in the Inputs panel below.
|
||||||
inputs: [
|
inputs: [
|
||||||
{ spec: { name: "epochs", dtype: "int" }, initial: 2 },
|
{ spec: { name: "epochs", dtype: "int" }, initial: 2 },
|
||||||
{ spec: { name: "rate", dtype: "float" }, initial: 0.5 },
|
{ spec: { name: "rate", dtype: "float" }, initial: 0.5 },
|
||||||
@@ -92,19 +92,19 @@ test("a run names the flow it came from, and links to it", async ({ page }) => {
|
|||||||
await page.waitForURL(`**/flows/${flowName}`)
|
await page.waitForURL(`**/flows/${flowName}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("an input the run never passed reads as the flow's own", async ({
|
test("an input the run never passed still reads its value", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await openRuns(page)
|
await openRuns(page)
|
||||||
await page.getByTestId("run-link").first().click()
|
await page.getByTestId("run-link").first().click()
|
||||||
|
|
||||||
const inputs = page.locator("section", { hasText: "Inputs" }).last()
|
const inputs = page.locator("section", { hasText: "Inputs" }).last()
|
||||||
// Passed, so no marker.
|
// `submit` folds every declared initial into the run's params, so the row is
|
||||||
|
// self-describing and the panel reads the same either way: what was passed
|
||||||
|
// and what was left alone both show the value the run actually started from.
|
||||||
await expect(inputs).toContainText("epochs")
|
await expect(inputs).toContainText("epochs")
|
||||||
// Declared and left alone: the value shows, and says where it came from.
|
|
||||||
await expect(inputs).toContainText("rate")
|
await expect(inputs).toContainText("rate")
|
||||||
await expect(inputs).toContainText("0.5")
|
await expect(inputs).toContainText("0.5")
|
||||||
await expect(inputs.getByText("default").first()).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a chart can be dragged into and double-clicked back out of", async ({
|
test("a chart can be dragged into and double-clicked back out of", async ({
|
||||||
|
|||||||
Reference in New Issue
Block a user