From e28cbf7d77cb9146943e5425a846d41b0700186b Mon Sep 17 00:00:00 2001 From: mignot Date: Thu, 17 Sep 2026 19:42:23 +0200 Subject: [PATCH] feat(genre-tree): add genre search with select-to-highlight Adds a search box to GenreTreeView for finding a genre by name and selecting it, which fetches its detail and highlights the matching node across all tree renderers (per-root, wheel, radial pop-core). Also bumps the @behindthemusictree/genre-tree-view catalog pin to 1.7.0. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +++ .../src/genre-tree/GenreSearch.test.tsx | 53 +++++++++++++++++ .../app-kit/src/genre-tree/GenreSearch.tsx | 52 +++++++++++++++++ .../src/genre-tree/GenreTreeView.test.tsx | 57 +++++++++++++++++++ .../app-kit/src/genre-tree/GenreTreeView.tsx | 22 +++++++ packages/app-kit/src/genre-tree/index.ts | 3 + .../src/genre-tree/lib/genre-search.test.ts | 45 +++++++++++++++ .../src/genre-tree/lib/genre-search.ts | 14 +++++ .../genre-tree/playlist-tree/TreePerRoot.tsx | 4 ++ .../genre-tree/playlist-tree/TreeWheel.tsx | 4 ++ .../playlist-tree/TreeWheelRadialPopCore.tsx | 4 ++ pnpm-lock.yaml | 14 ++--- pnpm-workspace.yaml | 2 +- 13 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 packages/app-kit/src/genre-tree/GenreSearch.test.tsx create mode 100644 packages/app-kit/src/genre-tree/GenreSearch.tsx create mode 100644 packages/app-kit/src/genre-tree/lib/genre-search.test.ts create mode 100644 packages/app-kit/src/genre-tree/lib/genre-search.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da8cb2b..fb9d9e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/app-kit/src/genre-tree/GenreSearch.test.tsx b/packages/app-kit/src/genre-tree/GenreSearch.test.tsx new file mode 100644 index 0000000..e92f82c --- /dev/null +++ b/packages/app-kit/src/genre-tree/GenreSearch.test.tsx @@ -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(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("shows matching results as the user types", () => { + render(); + + 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(); + + 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(); + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "amb" } }); + fireEvent.click(screen.getByText("Ambient")); + + expect(screen.getByRole("textbox")).toHaveValue(""); + expect(screen.queryByText("Ambient")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-kit/src/genre-tree/GenreSearch.tsx b/packages/app-kit/src/genre-tree/GenreSearch.tsx new file mode 100644 index 0000000..df3e460 --- /dev/null +++ b/packages/app-kit/src/genre-tree/GenreSearch.tsx @@ -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 ( +
+ setQuery(event.target.value)} + placeholder={placeholder} + aria-label="Search a genre" + /> + {results.length > 0 && ( +
    + {results.map((genrePlaylist) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx index 47502d5..4fff0cb 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx @@ -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"); + }); + }); }); diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.tsx index a029921..7c4cc0d 100644 --- a/packages/app-kit/src/genre-tree/GenreTreeView.tsx +++ b/packages/app-kit/src/genre-tree/GenreTreeView.tsx @@ -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"; @@ -71,6 +72,9 @@ export function GenreTreeView({ const [selectedGenreUuid, setSelectedGenreUuid] = useState( 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(null); const { data: selectedGenreDetail, isPending: isLoadingSelectedGenre } = useFetchGenreDetail(selectedGenreUuid, scope, getBackendBaseUrl); @@ -85,10 +89,19 @@ export function GenreTreeView({ 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. @@ -256,6 +269,12 @@ export function GenreTreeView({ )} + {!isLoading && ( + + )} {!isLoading && !readOnly && ( ({ additionalActions={additionalActions} onNodeClick={handleNodeClick} renderExtraDetails={renderExtraDetails} + selectedNodeId={selectedNodeId} readOnly={readOnly} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} @@ -330,6 +350,7 @@ export function GenreTreeView({ additionalActions={additionalActions} onNodeClick={handleNodeClick} renderExtraDetails={renderExtraDetails} + selectedNodeId={selectedNodeId} readOnly={readOnly} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} @@ -361,6 +382,7 @@ export function GenreTreeView({ additionalActions={additionalActions} onNodeClick={handleNodeClick} renderExtraDetails={renderExtraDetails} + selectedNodeId={selectedNodeId} readOnly={readOnly} showToolbar={showToolbar} /> diff --git a/packages/app-kit/src/genre-tree/index.ts b/packages/app-kit/src/genre-tree/index.ts index a31bf5c..7df0f4b 100644 --- a/packages/app-kit/src/genre-tree/index.ts +++ b/packages/app-kit/src/genre-tree/index.ts @@ -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, @@ -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"; diff --git a/packages/app-kit/src/genre-tree/lib/genre-search.test.ts b/packages/app-kit/src/genre-tree/lib/genre-search.test.ts new file mode 100644 index 0000000..227bb71 --- /dev/null +++ b/packages/app-kit/src/genre-tree/lib/genre-search.test.ts @@ -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([]); + }); +}); diff --git a/packages/app-kit/src/genre-tree/lib/genre-search.ts b/packages/app-kit/src/genre-tree/lib/genre-search.ts new file mode 100644 index 0000000..39cb83b --- /dev/null +++ b/packages/app-kit/src/genre-tree/lib/genre-search.ts @@ -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), + ); +}; diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx index ecd7403..6bebb35 100644 --- a/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx +++ b/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx @@ -38,6 +38,8 @@ export type GenrePlaylistTreePerRootProps = { 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. */ @@ -58,6 +60,7 @@ export default function GenrePlaylistTreePerRoot({ additionalActions, onNodeClick, renderExtraDetails, + selectedNodeId, readOnly = false, showToolbar, }: GenrePlaylistTreePerRootProps) { @@ -189,6 +192,7 @@ export default function GenrePlaylistTreePerRoot({ additionalActions={additionalActions} onNodeClick={onNodeClick} renderExtraDetails={renderExtraDetails} + selectedNodeId={selectedNodeId} showToolbar={showToolbar} /> ); diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx index 0f3f659..2ce3628 100644 --- a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx +++ b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx @@ -36,6 +36,8 @@ export type GenrePlaylistTreeWheelProps = { 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 @@ -58,6 +60,7 @@ export default function GenrePlaylistTreeWheel({ additionalActions, onNodeClick, renderExtraDetails, + selectedNodeId, readOnly = false, allowWheelRotation, showToolbar, @@ -189,6 +192,7 @@ export default function GenrePlaylistTreeWheel({ additionalActions={additionalActions} onNodeClick={onNodeClick} renderExtraDetails={renderExtraDetails} + selectedNodeId={selectedNodeId} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} /> diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx index 7561fef..85c42d6 100644 --- a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx +++ b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx @@ -34,6 +34,8 @@ export type GenrePlaylistTreeWheelRadialPopCoreProps = { 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 ring doesn't spin to the @@ -56,6 +58,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore additionalActions, onNodeClick, renderExtraDetails, + selectedNodeId, readOnly = false, allowWheelRotation, showToolbar, @@ -176,6 +179,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore additionalActions={additionalActions} onNodeClick={onNodeClick} renderExtraDetails={renderExtraDetails} + selectedNodeId={selectedNodeId} allowWheelRotation={allowWheelRotation} showToolbar={showToolbar} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7dfccb..c236e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,8 +10,8 @@ catalogs: specifier: 12.0.0 version: 12.0.0 '@behindthemusictree/genre-tree-view': - specifier: 1.6.1 - version: 1.6.1 + specifier: 1.7.0 + version: 1.7.0 '@behindthemusictree/ui': specifier: ^0.1.0 version: 0.1.0 @@ -31,7 +31,7 @@ importers: version: link:../../packages/app-kit '@behindthemusictree/genre-tree-view': specifier: 'catalog:' - version: 1.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/ui': specifier: 'catalog:' version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -77,7 +77,7 @@ importers: version: 12.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/genre-tree-view': specifier: 'catalog:' - version: 1.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@behindthemusictree/ui': specifier: 'catalog:' version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -267,8 +267,8 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' - '@behindthemusictree/genre-tree-view@1.6.1': - resolution: {integrity: sha512-fAy6jYBbwY08DNvDdiW8tBy86AC6xqpTJVEiEK/jGPg8FwYeOj/n9EAhzh5m9vkpSm6zEcTyxfuO4p1MLg+EUg==, tarball: https://npm.pkg.github.com/download/@behindthemusictree/genre-tree-view/1.6.1/a6b060b0f13912c289237133ab024e8bd67d28c3} + '@behindthemusictree/genre-tree-view@1.7.0': + resolution: {integrity: sha512-vn1A2+5Eg1gjT7HF9Lbm+SQZqEeQojxOo27lkIoSMEJHXLCVIu0l7+IzRw1sfRC+p5d67sdsEdjjoxz2+puxpg==, tarball: https://npm.pkg.github.com/download/@behindthemusictree/genre-tree-view/1.7.0/fade0c4f803c481adfc75a86d10104a7a01a39b9} peerDependencies: react: '>=18.0.0' react-dom: '>=18.0.0' @@ -2522,7 +2522,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@behindthemusictree/genre-tree-view@1.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@behindthemusictree/genre-tree-view@1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: d3: 7.9.0 react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 77c133c..956f205 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,7 @@ packages: catalog: "@behindthemusictree/brand": 12.0.0 - "@behindthemusictree/genre-tree-view": 1.6.1 + "@behindthemusictree/genre-tree-view": 1.7.0 "@behindthemusictree/ui": ^0.1.0 allowBuilds: