Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **genre-tree**: `GenreTreeView` now has a search box to find a genre by name and select it,
highlighting the matching node in the tree.

### Changed

- Bumped `@behindthemusictree/genre-tree-view` catalog pin to 1.7.0.

## [4.10.1] - 2026-09-11

### Changed
Expand Down
53 changes: 53 additions & 0 deletions packages/app-kit/src/genre-tree/GenreSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import GenreSearch from "./GenreSearch";
import { CriteriaPlaylistSimple } from "./schemas/criteria-playlist/simple";

const makeGenrePlaylist = (name: string): CriteriaPlaylistSimple => ({
uuid: name,
name,
criteria: null,
parent: null,
root: { uuid: "root", name: "root" },
tracksCount: 0,
createdOn: "2026-01-01T00:00:00Z",
updatedOn: null,
});

describe("GenreSearch", () => {
const genrePlaylists = [makeGenrePlaylist("Deep House"), makeGenrePlaylist("Ambient")];

it("renders no results before typing", () => {
render(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("shows matching results as the user types", () => {
render(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} />);

fireEvent.change(screen.getByRole("textbox"), { target: { value: "hou" } });

expect(screen.getByText("Deep House")).toBeInTheDocument();
expect(screen.queryByText("Ambient")).not.toBeInTheDocument();
});

it("calls onSelect with the matching genre playlist when a result is clicked", () => {
const onSelect = vi.fn();
render(<GenreSearch genrePlaylists={genrePlaylists} onSelect={onSelect} />);

fireEvent.change(screen.getByRole("textbox"), { target: { value: "amb" } });
fireEvent.click(screen.getByText("Ambient"));

expect(onSelect).toHaveBeenCalledWith(genrePlaylists[1]);
});

it("clears the query and results after a selection", () => {
render(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} />);

fireEvent.change(screen.getByRole("textbox"), { target: { value: "amb" } });
fireEvent.click(screen.getByText("Ambient"));

expect(screen.getByRole("textbox")).toHaveValue("");
expect(screen.queryByText("Ambient")).not.toBeInTheDocument();
});
});
52 changes: 52 additions & 0 deletions packages/app-kit/src/genre-tree/GenreSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { useState } from "react";
import { Input } from "@behindthemusictree/ui";

import { CriteriaPlaylistSimple } from "./schemas/criteria-playlist/simple";
import { searchGenrePlaylistsByName } from "./lib/genre-search";

export type GenreSearchProps = {
genrePlaylists: CriteriaPlaylistSimple[];
onSelect: (genrePlaylist: CriteriaPlaylistSimple) => void;
placeholder?: string;
};

export default function GenreSearch({
genrePlaylists,
onSelect,
placeholder = "Search a genre…",
}: GenreSearchProps) {
const [query, setQuery] = useState("");
const results = searchGenrePlaylistsByName(genrePlaylists, query);

return (
<div className="genre-search relative">
<Input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
aria-label="Search a genre"
/>
{results.length > 0 && (
<ul className="genre-search-results absolute z-20 mt-1 w-full max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-md">
{results.map((genrePlaylist) => (
<li key={genrePlaylist.uuid}>
<button
type="button"
className="w-full text-left px-3 py-1.5 hover:bg-gray-100"
onClick={() => {
onSelect(genrePlaylist);
setQuery("");
}}
>
{genrePlaylist.name}
</button>
</li>
))}
</ul>
)}
</div>
);
}
57 changes: 57 additions & 0 deletions packages/app-kit/src/genre-tree/GenreTreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -873,4 +873,61 @@ describe("GenreTreeView", () => {
expect(screen.getByText("—")).toBeInTheDocument();
});
});

describe("genre search", () => {
it("selecting a search result updates the info panel the same way a node click does", () => {
useListFullGenrePlaylistsMock.mockReturnValue({
data: { results: [makePlaylist({ uuid: "gp1", name: "Jazz", criteria: { uuid: "c1", name: "Jazz" } })] },
isPending: false,
});
useFetchGenreDetailMock.mockImplementation((id: string | null) =>
id === "c1"
? {
data: {
uuid: "c1",
name: "Jazz",
summary: "Improvised music with swung rhythms.",
tracksCount: 5,
tracksArchivedCount: 0,
children: [],
essentialTracks: [],
},
isPending: false,
}
: { data: undefined, isPending: false },
);
renderView();

fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), {
target: { value: "Jazz" },
});
fireEvent.click(screen.getByText("Jazz"));

expect(useFetchGenreDetailMock).toHaveBeenCalledWith("c1");

fireEvent.click(screen.getByRole("button", { name: "Wheel" }));
const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({
id: "gp1",
});
render(<>{output}</>);

expect(screen.getByText("Improvised music with swung rhythms.")).toBeInTheDocument();
});

it("passes the selected node id through to the active tree renderer for highlighting", () => {
useListFullGenrePlaylistsMock.mockReturnValue({
data: { results: [makePlaylist({ uuid: "gp1", name: "Jazz", criteria: { uuid: "c1", name: "Jazz" } })] },
isPending: false,
});
renderView();

fireEvent.click(screen.getByRole("button", { name: "Wheel" }));
fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), {
target: { value: "Jazz" },
});
fireEvent.click(screen.getByText("Jazz"));

expect(treeWheelPropsMock.mock.calls.at(-1)?.[0].selectedNodeId).toBe("gp1");
});
});
});
22 changes: 22 additions & 0 deletions packages/app-kit/src/genre-tree/GenreTreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import GenrePlaylistTreePerRoot from "./playlist-tree/TreePerRoot";
import GenrePlaylistTreeWheel from "./playlist-tree/TreeWheel";
import GenrePlaylistTreeWheelRadialPopCore from "./playlist-tree/TreeWheelRadialPopCore";
import { GenreTreeWheelHandoff } from "./GenreTreeWheelHandoff";
import GenreSearch from "./GenreSearch";

export type { GenreTreeViewMode } from "@behindthemusictree/genre-tree-view";

Expand Down Expand Up @@ -71,6 +72,9 @@ export function GenreTreeView<T extends TrackBase>({
const [selectedGenreUuid, setSelectedGenreUuid] = useState<string | null>(
null,
);
// The genre-playlist/node id (GenreTreeNode.id), distinct from selectedGenreUuid (the
// criteria id used to fetch detail) — passed to the tree renderers for visual highlighting.
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const { data: selectedGenreDetail, isPending: isLoadingSelectedGenre } =
useFetchGenreDetail(selectedGenreUuid, scope, getBackendBaseUrl);

Expand All @@ -85,10 +89,19 @@ export function GenreTreeView<T extends TrackBase>({
genrePlaylists?.results as CriteriaPlaylistSimple[] | undefined
)?.find((gp) => gp.uuid === node.id);
setSelectedGenreUuid(genrePlaylist?.criteria?.uuid ?? null);
setSelectedNodeId(node.id);
},
[genrePlaylists?.results],
);

const handleGenreSearchSelect = useCallback(
(genrePlaylist: CriteriaPlaylistSimple) => {
setSelectedGenreUuid(genrePlaylist.criteria?.uuid ?? null);
setSelectedNodeId(genrePlaylist.uuid);
},
[],
);

// The info panel can also navigate via its own ancestor/child chips, which don't go through
// onNodeClick — so the node passed here isn't guaranteed to be the one selectedGenreDetail was
// fetched for. Render nothing rather than stale essential tracks when they've diverged.
Expand Down Expand Up @@ -256,6 +269,12 @@ export function GenreTreeView<T extends TrackBase>({
</Button>
</div>
)}
{!isLoading && (
<GenreSearch
genrePlaylists={(genrePlaylists?.results ?? []) as CriteriaPlaylistSimple[]}
onSelect={handleGenreSearchSelect}
/>
)}
{!isLoading && !readOnly && (
<IconTextButton
icon={Plus}
Expand Down Expand Up @@ -302,6 +321,7 @@ export function GenreTreeView<T extends TrackBase>({
additionalActions={additionalActions}
onNodeClick={handleNodeClick}
renderExtraDetails={renderExtraDetails}
selectedNodeId={selectedNodeId}
readOnly={readOnly}
allowWheelRotation={allowWheelRotation}
showToolbar={showToolbar}
Expand Down Expand Up @@ -330,6 +350,7 @@ export function GenreTreeView<T extends TrackBase>({
additionalActions={additionalActions}
onNodeClick={handleNodeClick}
renderExtraDetails={renderExtraDetails}
selectedNodeId={selectedNodeId}
readOnly={readOnly}
allowWheelRotation={allowWheelRotation}
showToolbar={showToolbar}
Expand Down Expand Up @@ -361,6 +382,7 @@ export function GenreTreeView<T extends TrackBase>({
additionalActions={additionalActions}
onNodeClick={handleNodeClick}
renderExtraDetails={renderExtraDetails}
selectedNodeId={selectedNodeId}
readOnly={readOnly}
showToolbar={showToolbar}
/>
Expand Down
3 changes: 3 additions & 0 deletions packages/app-kit/src/genre-tree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
export * from "./GenreTreeView";
export { default as GenrePlaylistTreePerRoot } from "./playlist-tree/TreePerRoot";
export type { GenrePlaylistTreePerRootProps } from "./playlist-tree/TreePerRoot";
export { default as GenreSearch } from "./GenreSearch";
export type { GenreSearchProps } from "./GenreSearch";
export {
GenreTreeSkeleton,
GenreTreeWheelSkeleton,
Expand Down Expand Up @@ -66,3 +68,4 @@ export { libraryEndpoints, libraryQueryKeys } from "./api/library";
export * from "./lib/rating";
export * from "./lib/formatting";
export * from "./lib/genre-playlist-helpers";
export * from "./lib/genre-search";
45 changes: 45 additions & 0 deletions packages/app-kit/src/genre-tree/lib/genre-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { searchGenrePlaylistsByName } from "./genre-search";
import { CriteriaPlaylistSimple } from "../schemas/criteria-playlist/simple";

const makeGenrePlaylist = (name: string): CriteriaPlaylistSimple => ({
uuid: name,
name,
criteria: null,
parent: null,
root: { uuid: "root", name: "root" },
tracksCount: 0,
createdOn: "2026-01-01T00:00:00Z",
updatedOn: null,
});

describe("searchGenrePlaylistsByName", () => {
const genrePlaylists = [
makeGenrePlaylist("Deep House"),
makeGenrePlaylist("Tech House"),
makeGenrePlaylist("Ambient"),
];

it("returns nothing for an empty query", () => {
expect(searchGenrePlaylistsByName(genrePlaylists, "")).toEqual([]);
});

it("returns nothing for a whitespace-only query", () => {
expect(searchGenrePlaylistsByName(genrePlaylists, " ")).toEqual([]);
});

it("matches case-insensitively", () => {
expect(searchGenrePlaylistsByName(genrePlaylists, "house")).toEqual([
genrePlaylists[0],
genrePlaylists[1],
]);
});

it("matches a substring anywhere in the name", () => {
expect(searchGenrePlaylistsByName(genrePlaylists, "mbien")).toEqual([genrePlaylists[2]]);
});

it("returns an empty array when nothing matches", () => {
expect(searchGenrePlaylistsByName(genrePlaylists, "jazz")).toEqual([]);
});
});
14 changes: 14 additions & 0 deletions packages/app-kit/src/genre-tree/lib/genre-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CriteriaPlaylistSimple } from "../schemas/criteria-playlist/simple";

/** Case-insensitive substring match on genre name. An empty/whitespace query matches nothing. */
export const searchGenrePlaylistsByName = (
genrePlaylists: CriteriaPlaylistSimple[],
query: string,
): CriteriaPlaylistSimple[] => {
const trimmedQuery = query.trim().toLowerCase();
if (!trimmedQuery) return [];

return genrePlaylists.filter((genrePlaylist) =>
genrePlaylist.name.toLowerCase().includes(trimmedQuery),
);
};
4 changes: 4 additions & 0 deletions packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export type GenrePlaylistTreePerRootProps<T extends TrackBase> = {
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
onNodeClick?: (node: GenreTreeNode) => void;
renderExtraDetails?: (node: GenreTreeNode) => ReactNode;
/** Overrides which node is shown highlighted, e.g. from a search selection. */
selectedNodeId?: string | null;
/** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */
readOnly?: boolean;
/** When false, suppresses the hover toolbar on every node. Defaults to true. */
Expand All @@ -58,6 +60,7 @@ export default function GenrePlaylistTreePerRoot<T extends TrackBase>({
additionalActions,
onNodeClick,
renderExtraDetails,
selectedNodeId,
readOnly = false,
showToolbar,
}: GenrePlaylistTreePerRootProps<T>) {
Expand Down Expand Up @@ -189,6 +192,7 @@ export default function GenrePlaylistTreePerRoot<T extends TrackBase>({
additionalActions={additionalActions}
onNodeClick={onNodeClick}
renderExtraDetails={renderExtraDetails}
selectedNodeId={selectedNodeId}
showToolbar={showToolbar}
/>
);
Expand Down
4 changes: 4 additions & 0 deletions packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type GenrePlaylistTreeWheelProps<T extends TrackBase> = {
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
onNodeClick?: (node: GenreTreeNode) => void;
renderExtraDetails?: (node: GenreTreeNode) => ReactNode;
/** Overrides which node is shown highlighted, e.g. from a search selection. */
selectedNodeId?: string | null;
/** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */
readOnly?: boolean;
/** When false, clicking a chip still selects its root, but the wheel doesn't spin to the
Expand All @@ -58,6 +60,7 @@ export default function GenrePlaylistTreeWheel<T extends TrackBase>({
additionalActions,
onNodeClick,
renderExtraDetails,
selectedNodeId,
readOnly = false,
allowWheelRotation,
showToolbar,
Expand Down Expand Up @@ -189,6 +192,7 @@ export default function GenrePlaylistTreeWheel<T extends TrackBase>({
additionalActions={additionalActions}
onNodeClick={onNodeClick}
renderExtraDetails={renderExtraDetails}
selectedNodeId={selectedNodeId}
allowWheelRotation={allowWheelRotation}
showToolbar={showToolbar}
/>
Expand Down
Loading
Loading