Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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.

165 changes: 165 additions & 0 deletions client/src/protoFleet/components/MinerSearchInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useState } from "react";
import { fireEvent, render, screen } 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("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();
render(<MinerSearchInput initialValue="" onQueryChange={vi.fn()} 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.
expect(onQueryInput.mock.calls.map(([q]) => q)).toEqual(["r", "ra", ""]);
});

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("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();
});
});
94 changes: 94 additions & 0 deletions client/src/protoFleet/components/MinerSearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef } from "react";

import Search from "@/shared/components/Search";

const SEARCH_DEBOUNCE_MS = 250;

const MAX_SEARCH_QUERY_CODE_POINTS = 255;

// Leading whitespace never narrows a search, so it is dropped at the input,
// where `sanitize` applies it to the displayed text and the emitted value
// together. Trailing whitespace has to survive: stripping it would erase the
// space the moment it is typed and make multi-word queries impossible. Cap by
// Unicode code point rather than UTF-16 code unit so this matches the proto
// validator and the server's utf8.RuneCountInString check.
const sanitizeSearchQuery = (value: string) =>
Array.from(value.trimStart()).slice(0, MAX_SEARCH_QUERY_CODE_POINTS).join("");

interface MinerSearchInputProps {
initialValue?: string;
onQueryChange: (query: string) => void;
/** Fired synchronously on every keystroke, ahead of the debounce.
*
* Consumers that treat "a search is active" as a safety condition have to use
* this rather than `onQueryChange`: for the debounce interval the applied
* filter still reads as empty, so a selection gated on the applied filter
* stays armed while the field already shows a query. */
onQueryInput?: (query: string) => void;
id?: string;
}

/** Search control for miner lists. The visible input updates immediately while
* requests are debounced so typing does not issue one RPC per keystroke.
*
* The query is emitted exactly as typed. Callers persist it to the URL and feed
* it back as `initialValue`, and the input re-seeds itself from that prop, so
* emitting a normalized form would overwrite the text mid-entry — trimming here
* ate the space in "rack 7" whenever the debounce fired between the two words.
* Trimming belongs at the consumers, which already do it: the server trims
* search_query, and the miner list trims when reading the URL param. */
const MinerSearchInput = ({
initialValue = "",
onQueryChange,
onQueryInput,
id = "miner-search",
}: MinerSearchInputProps) => {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The pending timer must survive a new `onQueryChange` identity (the fleet
// table rebuilds it on every navigation), so the callback is read from a ref
// at fire time instead of being captured per keystroke.
const onQueryChangeRef = useRef(onQueryChange);
onQueryChangeRef.current = onQueryChange;
const onQueryInputRef = useRef(onQueryInput);
onQueryInputRef.current = onQueryInput;

const handleChange = useCallback((value: string) => {
onQueryInputRef.current?.(value);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onQueryChangeRef.current(value);
}, SEARCH_DEBOUNCE_MS);
Comment on lines +55 to +60
}, []);

const previousInitialValueRef = useRef(initialValue);
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (previousInitialValueRef.current !== initialValue) {
previousInitialValueRef.current = initialValue;
onQueryInputRef.current?.(initialValue);
}
}, [initialValue]);

useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);

return (
<Search
id={id}
label="Search miners"
variant="toolbar"
initValue={initialValue}
onChange={handleChange}
sanitize={sanitizeSearchQuery}
/>
);
};

export default MinerSearchInput;
64 changes: 62 additions & 2 deletions client/src/protoFleet/components/MinerSelectionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,12 +376,72 @@ describe("MinerSelectionList eligibility", () => {
expect(titles).not.toContain("Building");
});

it("renders the assignable-only toggle only when eligibility is provided", () => {
it("renders miner search for every list and the assignable-only toggle only with eligibility", () => {
const { rerender } = render(<MinerSelectionList />);
expect(lastListProps()?.headerControls).toBeFalsy();
expect(lastListProps()?.headerControls).toBeTruthy();
expect(screen.queryByLabelText("Show assigned miners")).not.toBeInTheDocument();

rerender(<MinerSelectionList eligibility={{ rackId: 1n }} />);
expect(lastListProps()?.headerControls).toBeTruthy();
expect(screen.getByLabelText("Show assigned miners")).toBeInTheDocument();
});

it("debounces the search query before fetching", async () => {
vi.useFakeTimers();
try {
render(<MinerSelectionList />);
const input = screen.getByLabelText("Search miners");

fireEvent.change(input, { target: { value: "worker-42" } });
expect(lastFleetFilter().searchQuery).toBe("");

await act(async () => {
await vi.advanceTimersByTimeAsync(249);
});
expect(lastFleetFilter().searchQuery).toBe("");

await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(lastFleetFilter().searchQuery).toBe("worker-42");
expect(screen.queryByText("Select all")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});

it("withdraws select-all on the first keystroke, not when the debounce lands", async () => {
vi.useFakeTimers();
try {
render(<MinerSelectionList />);
const input = screen.getByLabelText("Search miners");
expect(screen.queryByText("Select all")).toBeInTheDocument();

fireEvent.change(input, { target: { value: "worker-42" } });

// The applied filter still reads as empty here. Gating select-all on it
// would leave a window where submitting all-mode targets the whole fleet
// while the field already shows a query.
expect(lastFleetFilter().searchQuery).toBe("");
expect(screen.queryByText("Select all")).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});

it("drops an existing all-selection as soon as the operator starts narrowing", async () => {
vi.useFakeTimers();
try {
render(<MinerSelectionList />);
fireEvent.click(screen.getByText("Select all"));
expect(screen.queryByText(/All \d+ miners selected/)).toBeInTheDocument();

fireEvent.change(screen.getByLabelText("Search miners"), { target: { value: "worker-42" } });

expect(screen.queryByText(/All \d+ miners selected/)).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});

it("applies eligibility server-side by default and drops it when 'Show assigned miners' is on", () => {
Expand Down
Loading
Loading