Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1a247de
feat: add global miner search
mcharles-square Aug 26, 2026
bd7619e
fix(review): scope bulk select-all to the active miner search
mcharles-square Aug 26, 2026
35f6398
refactor(sqlstores): extract the static-vs-dynamic query routing pred…
mcharles-square Aug 26, 2026
da6b06a
fix(client): stop the miner search from rewriting text mid-entry
mcharles-square Aug 26, 2026
5a5149c
feat(client): add a toolbar variant to Search
mcharles-square Aug 26, 2026
f9f465a
fix(client): disarm select-all on keystroke, not after the search deb…
mcharles-square Aug 26, 2026
b9ab512
fix(client): harden global miner search boundaries
Aug 28, 2026
578ba33
feat(client): collapse global miner search by default
Sep 4, 2026
e0a643e
chore(gen): apply goimports grouping to fleetmanagement bindings
mcharles-square Sep 14, 2026
de45d21
refactor(client): drop the unreachable search branch from the select-…
mcharles-square Sep 14, 2026
7a21f94
docs(fleetmanagement): note that search matches the display-name fall…
mcharles-square Sep 14, 2026
9515211
fix(fleetmanagement): inset the expanded miner search on phones
mcharles-square Sep 14, 2026
5460d37
test(client): query the miner search story through its label association
mcharles-square Sep 14, 2026
07a1fbe
fix(list): expand the collapsible search without shifting its row
mcharles-square Sep 14, 2026
4c707e7
fix(minerlist): disarm the selection on the first search keystroke
mcharles-square Sep 14, 2026
da4d37e
fix(search): keep the picker search mounted and drop pending queries …
mcharles-square Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/e2eTests/protoFleet/pages/singleMiner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ export class SingleMinerPage extends BasePage {
}

async searchLogs(query: string) {
await this.page.getByLabel("Search").fill(query);
await this.page.getByRole("textbox", { name: "Search" }).fill(query);
}

async validateLogsSearchQuery(expectedQuery: string) {
await expect(this.page.getByLabel("Search")).toHaveValue(expectedQuery);
await expect(this.page.getByRole("textbox", { name: "Search" })).toHaveValue(expectedQuery);
}

async navigateToAuthenticationSettings() {
Expand Down
10 changes: 7 additions & 3 deletions client/e2eTests/protoOS/pages/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { expect } from "@playwright/test";
import { BasePage } from "./base";

export class LogsPage extends BasePage {
private searchInput() {
return this.page.getByRole("textbox", { name: "Search" });
}

async validateLogsPageOpened() {
await expect(this.page).toHaveURL(/.*\/logs/);
await expect(this.page.getByLabel("Search")).toBeVisible();
await expect(this.searchInput()).toBeVisible();
await expect(this.page.getByRole("button", { name: "Export" })).toBeVisible();
}

Expand All @@ -21,12 +25,12 @@ export class LogsPage extends BasePage {
}

async searchLogs(query: string) {
const searchInput = this.page.getByLabel("Search");
const searchInput = this.searchInput();
await searchInput.fill(query);
}

async clearSearch() {
const searchInput = this.page.getByLabel("Search");
const searchInput = this.searchInput();
await searchInput.focus();
await searchInput.press("Escape");
await expect(searchInput).toHaveValue("");
Expand Down

Large diffs are not rendered by default.

238 changes: 238 additions & 0 deletions client/src/protoFleet/components/MinerSearchInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { useState } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import MinerSearchInput from "./MinerSearchInput";

const SEARCH_DEBOUNCE_MS = 250;

/** Mirrors the miner list: the emitted query is persisted (there, to the URL)
* and handed straight back as `initialValue`. That round-trip is what makes a
* normalized emit overwrite the text the operator is still typing. */
const RoundTripHarness = ({ onQueryChange }: { onQueryChange?: (q: string) => void } = {}) => {
const [persisted, setPersisted] = useState("");
return (
<MinerSearchInput
initialValue={persisted}
onQueryChange={(query) => {
onQueryChange?.(query);
setPersisted(query);
}}
/>
);
};

const searchBox = () => screen.getByRole("textbox", { name: /search miners/i });

describe("MinerSearchInput", () => {
afterEach(() => {
vi.useRealTimers();
});

it("collapses behind a search icon, expands with focus, and collapses again on empty blur", async () => {
render(<MinerSearchInput collapsible initialValue="" onQueryChange={vi.fn()} />);

const toggle = screen.getByRole("button", { name: "Search miners" });
expect(screen.queryByRole("textbox", { name: /search miners/i })).not.toBeInTheDocument();

fireEvent.click(toggle);
await waitFor(() => expect(searchBox()).toHaveFocus());

fireEvent.blur(searchBox());
expect(screen.queryByRole("textbox", { name: /search miners/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Search miners" })).toBeInTheDocument();
});

it("collapses when the empty field's clear affordance is used", async () => {
render(<MinerSearchInput collapsible initialValue="" onQueryChange={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Search miners" }));
await waitFor(() => expect(searchBox()).toHaveFocus());

fireEvent.click(screen.getByRole("button", { name: "Clear Search miners" }));

expect(screen.queryByRole("textbox", { name: /search miners/i })).not.toBeInTheDocument();
});

it("keeps a non-empty search expanded when focus leaves", () => {
render(<MinerSearchInput collapsible initialValue="rack-7" onQueryChange={vi.fn()} />);

fireEvent.blur(searchBox());

expect(searchBox()).toHaveValue("rack-7");
});

it("clears an active query immediately and stays expanded for refinement", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
render(<MinerSearchInput collapsible initialValue="rack-7" onQueryChange={onQueryChange} />);

fireEvent.click(screen.getByRole("button", { name: "Clear Search miners" }));

expect(onQueryChange).toHaveBeenCalledExactlyOnceWith("");
expect(searchBox()).toHaveValue("");
expect(searchBox()).toHaveFocus();
});

it("keeps a trailing space through the round-trip so multi-word queries stay typable", () => {
vi.useFakeTimers();
render(<RoundTripHarness />);

// Pausing mid-query is the trigger: the debounce fires, the value is
// persisted, and it comes back as initialValue while the field still has
// focus. Trimming on the way out deleted the space, turning the next
// keystroke into "rack7".
fireEvent.change(searchBox(), { target: { value: "rack " } });
vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);

expect(searchBox()).toHaveValue("rack ");

fireEvent.change(searchBox(), { target: { value: "rack 7" } });
vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);

expect(searchBox()).toHaveValue("rack 7");
});

it("emits the query as typed rather than a normalized form", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
render(<RoundTripHarness onQueryChange={onQueryChange} />);

fireEvent.change(searchBox(), { target: { value: "rack 7 " } });
vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);

expect(onQueryChange).toHaveBeenCalledWith("rack 7 ");
});

it("drops leading whitespace from both the field and the emitted query", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
render(<RoundTripHarness onQueryChange={onQueryChange} />);

fireEvent.change(searchBox(), { target: { value: " rack" } });

// Applied at the input, so the visible text and the emitted value agree and
// the echo cannot rewrite the field.
expect(searchBox()).toHaveValue("rack");

vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);
expect(onQueryChange).toHaveBeenCalledWith("rack");
});

it("issues one query per typing burst rather than one per keystroke", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
render(<RoundTripHarness onQueryChange={onQueryChange} />);

fireEvent.change(searchBox(), { target: { value: "r" } });
fireEvent.change(searchBox(), { target: { value: "ra" } });
fireEvent.change(searchBox(), { target: { value: "rack" } });
expect(onQueryChange).not.toHaveBeenCalled();

vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);
expect(onQueryChange).toHaveBeenCalledExactlyOnceWith("rack");
});

it("reports typing synchronously so safety gates do not wait for the debounce", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
const onQueryInput = vi.fn();
render(<MinerSearchInput initialValue="" onQueryChange={onQueryChange} onQueryInput={onQueryInput} />);

fireEvent.change(searchBox(), { target: { value: "rack" } });

// Consumers gate destructive all-mode selections on "is a search active".
// If that only became true after the debounce, an all-mode action submitted
// inside the window would apply to the whole fleet while the field already
// showed a query.
expect(onQueryInput).toHaveBeenCalledExactlyOnceWith("rack");
expect(onQueryChange).not.toHaveBeenCalled();

vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);
expect(onQueryChange).toHaveBeenCalledExactlyOnceWith("rack");
});

it("reports every keystroke synchronously, not just the first", () => {
vi.useFakeTimers();
const onQueryInput = vi.fn();
const onQueryChange = vi.fn();
render(<MinerSearchInput initialValue="" onQueryChange={onQueryChange} onQueryInput={onQueryInput} />);

fireEvent.change(searchBox(), { target: { value: "r" } });
fireEvent.change(searchBox(), { target: { value: "ra" } });
fireEvent.change(searchBox(), { target: { value: "" } });

// Clearing has to report too, or the gate would stay latched shut. It also
// clears the applied query synchronously instead of waiting for debounce.
expect(onQueryInput.mock.calls.map(([q]) => q)).toEqual(["r", "ra", ""]);
expect(onQueryChange).toHaveBeenCalledExactlyOnceWith("");
});

it("cancels a pending query when an external value replaces it", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
const onQueryInput = vi.fn();
const { rerender } = render(
<MinerSearchInput initialValue="" onQueryChange={onQueryChange} onQueryInput={onQueryInput} />,
);

fireEvent.change(searchBox(), { target: { value: "rack" } });
rerender(<MinerSearchInput initialValue="saved-view" onQueryChange={onQueryChange} onQueryInput={onQueryInput} />);
vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);

expect(searchBox()).toHaveValue("saved-view");
expect(onQueryChange).not.toHaveBeenCalled();
expect(onQueryInput).toHaveBeenLastCalledWith("saved-view");
});

it("reports the collapse when an externally applied query is cleared again", () => {
const onExpandedChange = vi.fn();
const { rerender } = render(
<MinerSearchInput collapsible initialValue="" onQueryChange={vi.fn()} onExpandedChange={onExpandedChange} />,
);
expect(onExpandedChange).not.toHaveBeenCalled();

rerender(
<MinerSearchInput
collapsible
initialValue="saved-view"
onQueryChange={vi.fn()}
onExpandedChange={onExpandedChange}
/>,
);
expect(searchBox()).toHaveValue("saved-view");
expect(onExpandedChange).toHaveBeenCalledExactlyOnceWith(true);

rerender(
<MinerSearchInput collapsible initialValue="" onQueryChange={vi.fn()} onExpandedChange={onExpandedChange} />,
);
expect(screen.getByRole("button", { name: "Search miners" })).toBeInTheDocument();
expect(onExpandedChange).toHaveBeenLastCalledWith(false);
expect(onExpandedChange).toHaveBeenCalledTimes(2);
});

it("caps search text at the API's 255 Unicode code-point limit", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
render(<MinerSearchInput initialValue="" onQueryChange={onQueryChange} />);

const query = `${"a".repeat(254)}🐝extra`;
fireEvent.change(searchBox(), { target: { value: query } });
vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);

const expected = `${"a".repeat(254)}🐝`;
expect(searchBox()).toHaveValue(expected);
expect(onQueryChange).toHaveBeenCalledExactlyOnceWith(expected);
});

it("cancels a pending query when unmounted mid-debounce", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
const { unmount } = render(<RoundTripHarness onQueryChange={onQueryChange} />);

fireEvent.change(searchBox(), { target: { value: "rack" } });
unmount();
vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS);

expect(onQueryChange).not.toHaveBeenCalled();
});
});
Loading
Loading